Skip to content

Feat/classification UI refactor#94

Merged
emilwojtaszek merged 22 commits into
mainfrom
feat/classification-ui-refactor
Mar 18, 2026
Merged

Feat/classification UI refactor#94
emilwojtaszek merged 22 commits into
mainfrom
feat/classification-ui-refactor

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Mar 18, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • AI-powered emoji generation for invoice categories
    • Dedicated category and tag management pages; new manual invoice classification page with prediction hints
  • Improvements

    • Category identifiers (group:target) as primary key with optional display names and examples; UI falls back to identifier when name is missing
    • Invoice view now shows read-only classification with a separate classify/edit flow and updated routes
  • Docs & Config

    • Anthropic API key documented and enabled for emoji generation
  • Migrations & Tests

    • Database migration for category identifier changes and expanded test coverage

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an Anthropic-backed emoji generator, migrates category primary key from name→identifier (schema, DB, validations, APIs), refactors Category/Tag LiveViews into Index/Form routes, introduces an Invoice classify LiveView, updates serializers, router, tests, and adds a migration and runtime/config wiring for ANTHROPIC_API_KEY.

Changes

Cohort / File(s) Summary
Env & Sidecar
\.env.example, CLAUDE.md, cloud-run/service.yaml, docs/sidecar-services.md
Keep ANTHROPIC_API_KEY, remove ANTHROPIC_MODEL entries; update docs/comments to indicate the API key is used by invoice-extractor sidecar and Elixir emoji generation.
Config & Runtime
config/config.exs, config/runtime.exs, config/test.exs
Add :emoji_generator config, wire ANTHROPIC_API_KEY into runtime config, and configure test mocks/Req options for emoji generator.
Emoji generator
lib/ksef_hub/emoji_generator.ex, lib/ksef_hub/emoji_generator/behaviour.ex, lib/ksef_hub/emoji_generator/client.ex, test/ksef_hub/emoji_generator*
New behaviour and delegating module; client implementing Anthropic/Req requests, prompt construction, emoji extraction, error handling, and tests validating success/failure and extraction logic.
Category model & DB
lib/ksef_hub/invoices/category.ex, lib/ksef_hub_web/schemas/category.ex, priv/repo/migrations/...extend_categories.exs
Introduce identifier (required), add nullable name and examples, update changeset/validations and unique index; migration renames nameidentifier and adds new columns/index.
Controllers / JSON / API schemas
lib/ksef_hub_web/controllers/api/category_controller.ex, lib/ksef_hub_web/json_helpers.ex, lib/ksef_hub_web/schemas/create_category_request.ex, .../update_category_request.ex, .../update_tag_request.ex
API now accepts/returns identifier and examples; OpenApiSpex schemas updated (identifier required); type added to UpdateTagRequest.
LiveView refactor — Categories & Tags
lib/ksef_hub_web/live/category_live.ex (removed), .../category_live/form.ex (new), .../category_live/index.ex (new), lib/ksef_hub_web/live/tag_live.ex (removed), .../tag_live/form.ex (new), .../tag_live/index.ex (new)
Replace monolithic LiveViews with route-backed Index and Form LiveViews; Form includes async emoji generation, validation, and standardize create/edit flows.
Invoice classification UI & show changes
lib/ksef_hub_web/live/invoice_live/classify.ex, lib/ksef_hub_web/live/invoice_live/show.ex, lib/ksef_hub_web/live/invoice_live/index.ex
Add Invoice classify LiveView (grouped categories, tag creation, prediction hints); convert invoice show to read-only classification display and fall back to identifier when name is absent.
Invoice classifier & exports
lib/ksef_hub/invoice_classifier.ex, lib/ksef_hub/invoices.ex, lib/ksef_hub/exports/csv_builder.ex
Switch classifier/category resolution to identifier-based lookup, rename confidence flags, update ordering/filtering to use identifier, and CSV fallback to identifier.
Web components & router
lib/ksef_hub_web/components/invoice_components.ex, lib/ksef_hub_web/router.ex
Add prediction_hint component and fallback to identifier in category_badge; update routes to new Index/Form LiveViews and add /invoices/:id/classify.
Tests, factories & helpers
test/**/*, test/support/factory.ex, test/test_helper.exs
Widespread test updates to use identifier, new emoji generator tests and Mox mock, new LiveView tests for classify and refactored flows, and factory updated to generate identifiers.
Misc small edits
lib/ksef_hub_web/json_helpers.ex, lib/ksef_hub/exports/csv_builder.ex
Serialization and CSV formatting updated to include/return identifier and examples, with small fallback behavior changes.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/Form
    participant LiveView as CategoryLive.Form
    participant Supervisor as Task.Supervisor
    participant Emoji as EmojiGenerator.Client
    participant Claude as Anthropic API

    rect rgba(200,200,255,0.5)
    User->>LiveView: click "Generate Emoji"
    LiveView->>Supervisor: start async task (Task.Supervisor.async_nolink)
    Supervisor->>Emoji: generate_emoji(context)
    end

    rect rgba(200,255,200,0.5)
    Emoji->>Emoji: build_prompt(identifier,name,description,examples)
    Emoji->>Claude: POST /v1/messages (x-api-key header)
    Claude-->>Emoji: response (text content)
    Emoji->>Emoji: extract_first_emoji(text)
    Emoji-->>Supervisor: {:ok, emoji} or {:error, reason}
    Supervisor-->>LiveView: handle_info callback (update form)
    LiveView-->>User: render form with emoji
    end
Loading
sequenceDiagram
    participant User as User/UI
    participant Classify as InvoiceLive.Classify
    participant Invoices as Invoices Context
    participant DB as Database

    User->>Classify: mount with invoice id
    Classify->>Invoices: get_invoice(company_id,id)
    Invoices->>DB: fetch invoice
    DB-->>Invoices: invoice
    Invoices-->>Classify: invoice struct
    Classify->>Invoices: list_categories(company_id)
    Invoices->>DB: fetch categories
    DB-->>Invoices: categories
    Invoices-->>Classify: categories list

    User->>Classify: select category / toggle tag
    Classify->>Classify: update local state (selected, expanded)
    User->>Classify: save classification
    Classify->>Invoices: with_manual_prediction(invoice, attrs)
    Invoices->>DB: update invoice prediction/tags
    DB-->>Invoices: result
    Invoices-->>Classify: {:ok, updated_invoice}
    Classify-->>User: navigate to invoice show
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐇 I hopped where names became keys,

I nudged Claude for tiny emoji fees,
Forms split, classifiers learned to mind,
Migrations marched and tests aligned,
A rabbit cheers: "New routes and tidy trees!"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/classification UI refactor' clearly describes the main change: refactoring the classification UI. It is concise, specific enough to convey the primary purpose, and aligns with the substantial UI-related changes throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/classification-ui-refactor
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
lib/ksef_hub_web/controllers/api/category_controller.ex (2)

89-91: ⚠️ Potential issue | 🟡 Minor

Update validation error description to reflect identifier-based semantics.

The error description still mentions "name is required and must be unique" but the schema now requires identifier to be unique per company, not name.

📝 Proposed fix
       422 =>
-        {"Validation error — name is required and must be unique within the company",
+        {"Validation error — identifier is required and must be unique within the company",
          "application/json", Schemas.ErrorResponse}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/category_controller.ex` around lines 89 -
91, Update the 422 error description in
lib/ksef_hub_web/controllers/api/category_controller.ex where the response map
contains the tuple 422 => {"Validation error — name is required and must be
unique within the company", "application/json", Schemas.ErrorResponse}; change
the human-readable message to state that "identifier is required and must be
unique within the company" (or similar identifier-based wording) so it matches
the schema semantics for identifier uniqueness while leaving the content-type
and Schemas.ErrorResponse reference intact.

130-132: ⚠️ Potential issue | 🟡 Minor

Update validation error description for update operation.

Same issue as create — the description mentions "name must be unique" but the unique constraint is now on identifier.

📝 Proposed fix
       422 =>
-        {"Validation error — name must be unique within the company", "application/json",
+        {"Validation error — identifier must be unique within the company", "application/json",
          Schemas.ErrorResponse}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/category_controller.ex` around lines 130 -
132, The 422 error mapping in CategoryController's update response currently
says "name must be unique within the company" but the unique constraint is on
identifier; update the 422 tuple string to reference "identifier must be unique
within the company" so the error description matches the constraint (look for
the 422 => {"Validation error — name must be unique within the company",
"application/json", Schemas.ErrorResponse} tuple in
lib/ksef_hub_web/controllers/api/category_controller.ex and replace the message
text accordingly).
🧹 Nitpick comments (12)
lib/ksef_hub_web/live/category_live/form.ex (1)

98-103: Consider adding a timeout for emoji generation.

If EmojiGenerator.generate_emoji/1 hangs or takes too long, the emoji_loading state will remain true indefinitely. Consider adding a Process.send_after timeout to reset the loading state.

💡 Optional: Add timeout handling
      Task.Supervisor.async_nolink(KsefHub.TaskSupervisor, fn ->
        EmojiGenerator.generate_emoji(context)
      end)

+      # Reset loading state after 30 seconds if no response
+      Process.send_after(self(), :emoji_generation_timeout, :timer.seconds(30))
+
      {:noreply, socket}
    end
  end

Then add a handler:

def handle_info(:emoji_generation_timeout, %{assigns: %{emoji_loading: true}} = socket) do
  {:noreply,
   socket
   |> assign(emoji_loading: false)
   |> put_flash(:error, "Emoji generation timed out.")}
end

def handle_info(:emoji_generation_timeout, socket), do: {:noreply, socket}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/category_live/form.ex` around lines 98 - 103, The async
task that calls EmojiGenerator.generate_emoji/1 via Task.Supervisor.async_nolink
currently has no timeout, so emoji_loading can remain true forever; add a
Process.send_after/3 when starting the task to send a timeout message (e.g.
:emoji_generation_timeout) after a chosen interval, and implement handle_info
clauses to catch :emoji_generation_timeout — one that checks %{assigns:
%{emoji_loading: true}} and resets assign(:emoji_loading, false) and sets a
flash error, and a fallback handle_info(:emoji_generation_timeout, socket) ->
{:noreply, socket}; keep references to the existing Task.Supervisor.async_nolink
call, EmojiGenerator.generate_emoji, the emoji_loading assign, and the new
:emoji_generation_timeout handler names so the change integrates with the
current flow.
lib/ksef_hub/emoji_generator/behaviour.ex (1)

4-9: Make name, description, and examples keys optional in the category_context typespec.

The implementation accesses identifier directly (context.identifier) but uses bracket syntax for the other fields (context[:name], context[:description], context[:examples]), which gracefully handle missing keys. Test cases already pass partial maps with only an identifier. Update the typespec to reflect this:

`@type` category_context :: %{
-         identifier: String.t(),
-         name: String.t() | nil,
-         description: String.t() | nil,
-         examples: String.t() | nil
+         required(:identifier) => String.t(),
+         optional(:name) => String.t() | nil,
+         optional(:description) => String.t() | nil,
+         optional(:examples) => String.t() | nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/emoji_generator/behaviour.ex` around lines 4 - 9, The typespec
for category_context currently marks name, description and examples as required
but the code accesses them with bracket syntax and tests pass maps that only
include identifier; update the `@type` category_context to make these keys
optional by changing the map spec to use required(:identifier) for identifier
and optional(:name), optional(:description), optional(:examples) for the other
keys (each typed as String.t() | nil) so the typespec matches runtime usage in
the behaviour module.
test/ksef_hub_web/live/invoice_live/classify_test.exs (1)

184-187: Scope the checked assertion to the created tag to reduce flakiness.

has_element?(view, ~s(input[checked])) is broad and can pass due to unrelated checked inputs.

Based on learnings: "Always reference the key element IDs you added in the LiveView templates in your tests for Phoenix.LiveViewTest functions like element/2, has_element/2, selectors, etc".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/invoice_live/classify_test.exs` around lines 184 -
187, The checked-input assertion is too broad (has_element?/2 with
~s(input[checked])) and can be triggered by unrelated inputs; update the test
after render(view) to scope the selector to the specific tag you created (use
the tag's DOM id or a unique surrounding element) and call has_element?(view,
"<scoped selector for that tag>") or use element(view, "<scoped selector>") then
assert its checked state—target the unique id/class for the "brand-new-tag"
input so only that input is matched.
lib/ksef_hub_web/live/tag_live/form.ex (1)

10-20: Add @doc for public callbacks in this LiveView.

Public callbacks are typed but undocumented.

As per coding guidelines: "Every public function must have @doc documentation in Elixir".

Also applies to: 61-77, 129-166

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 10 - 20, Add `@doc`
strings for all public callbacks and functions in this LiveView module: at
minimum document mount/3 and handle_params/3 (both are public callbacks), and
likewise add `@doc` for the other public functions in the file referenced (the
functions in the blocks you noted around lines 61-77 and 129-166). For each
function (e.g., mount, handle_params and any public helpers or callbacks in
those ranges) add a short description of purpose, the expected args and return
shape (socket updates or {:noreply, socket}), and any important side effects so
the module adheres to the "every public function must have `@doc`" guideline.
lib/ksef_hub_web/live/tag_live/index.ex (1)

13-52: Add @doc for public LiveView callbacks.

mount/3, handle_event/3, and render/1 are public and currently undocumented.

As per coding guidelines: "Every public function must have @doc documentation in Elixir".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/index.ex` around lines 13 - 52, Add `@doc`
annotations to the public LiveView callbacks mount/3, handle_event/3, and
render/1 describing their purpose and expected parameters/returns; for mount/3
explain it initializes assigns (page_title and company_id) and streams tags, for
handle_event/3 describe handling the "delete" event and possible outcomes
(invalid id, not found, deletion error), and for render/1 state that it returns
the LiveView rendered template using assigns—place each `@doc` immediately above
the corresponding function definition (mount, handle_event, render).
lib/ksef_hub_web/live/category_live/index.ex (1)

12-50: Add @doc to public callbacks in this module.

mount/3, handle_event/3, and render/1 should be documented to satisfy module-level API standards.

As per coding guidelines: "Every public function must have @doc documentation in Elixir".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/category_live/index.ex` around lines 12 - 50, Add `@doc`
documentation for the public callbacks mount/3, handle_event/3, and render/1 in
this LiveView module: place a short descriptive `@doc` above the mount/3 function
explaining it mounts the socket and assigns page_title and streams categories
for the current company, above handle_event/3 describing the "delete" event flow
(ID casting, lookup, deletion, and flash/stream updates), and above render/1
stating it returns the LiveView rendering; include brief notes on params and
return types consistent with existing `@specs` and keep the docs concise.
lib/ksef_hub_web/live/invoice_live/show.ex (1)

517-519: Drop find_category/2 and render the preloaded association directly.

Invoices.get_invoice_with_details/3 already preloads :category, so keeping a full @categories assign just to Enum.find/2 the current one adds an extra query and more socket state than this page needs.

Based on learnings: Applies to **/*_live.ex : Keep socket assigns minimal and flat when possible.

Also applies to: 746-750

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/show.ex` around lines 517 - 519, Remove
the find_category/2 helper and stop assigning `@categories`;
Invoices.get_invoice_with_details/3 already preloads the :category association,
so update templates/assigns to use the preloaded invoice.category directly
(replace calls to find_category(invoice.category_id, `@categories`) or similar
with invoice.category), remove the `@categories` socket assign and any Enum.find
usage, and delete the private function find_category/2; apply the same change
for the other occurrence referenced (the block around the second occurrence).
test/ksef_hub_web/live/tag_live_test.exs (2)

24-24: Mirror the TagLive.Index / TagLive.Form split in the test layout too.

These describe blocks now cover two different LiveViews, but they still live in one catch-all test module. Moving them into mirrored index_test.exs / form_test.exs files will make failures much easier to localize.

As per coding guidelines: test/**/*_test.exs: Test files must mirror source path structure (e.g., test/ksef_hub/invoices_test.exs for lib/ksef_hub/invoices.ex).

Also applies to: 70-70, 102-102

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/tag_live_test.exs` at line 24, The test module
currently mixes two LiveView specs; split the describe blocks for TagLive.Index
and TagLive.Form into two separate test files mirroring the source layout:
create test/ksef_hub_web/live/tag_live/index_test.exs for tests targeting
TagLive.Index and test/ksef_hub_web/live/tag_live/form_test.exs for tests
targeting TagLive.Form, move the respective describe blocks and any helper setup
into the corresponding files, update module names and describe titles to reflect
TagLive.Index and TagLive.Form, and ensure any shared setup or helpers are
extracted into a common support module or imported so both new test files run
independently and follow the test/**/*_test.exs naming guideline.

25-130: Use selectors instead of raw HTML/text matches in these LiveView tests.

html =~ and render(view) here are tied to incidental markup/text, so small template tweaks will make the suite noisy. The views already expose stable hooks like #tags, #tag-form, and data-testid="tag-name-#{tag.id}"; please switch these checks to has_element?/2 and element/2.

Based on learnings: Applies to /test//*_live_test.{exs,ex} : Never test against raw HTML - always use element/2, has_element/2, and similar functions with selectors like assert has_element?(view, "#my-form").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/tag_live_test.exs` around lines 25 - 130, Replace raw
HTML/text assertions with selector-based LiveView helpers: use has_element?/2
and element/2 instead of html =~ or render(view) checks. For list/show tests
assert has_element?(view_or_html, "#tags") and assert has_element?(view,
~s([data-testid="tag-name-#{tag.id}"])) to check tag presence; for deletes use
element(view, ~s([data-testid="tag-name-#{tag.id}"])) |> render_click(...) (or
render_click on the delete button selector) and then refute has_element?(view,
~s([data-testid="tag-name-#{tag.id}"])). For forms keep element("form#tag-form")
and render_submit/2, and keep assert_redirect(view, ...) and flash checks as-is
(assert_redirect/2, render_submit/1, render_click/1, has_element?/2, element/2).
test/ksef_hub_web/live/invoice_live/show_test.exs (2)

277-316: Scope these classification assertions to the new selectors.

The page now exposes category-display, tags-display, and edit-classification, but these tests still fall back to render(view) text matching. That can pass if the same text shows up elsewhere on the page and won't catch a bad classify href.

Based on learnings: Applies to /test//*_live_test.{exs,ex} : Always reference the key element IDs you added in the LiveView templates in your tests for Phoenix.LiveViewTest functions like element/2, has_element/2, selectors, etc.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs` around lines 277 - 316,
Update the tests to assert against the specific LiveView DOM selectors instead
of relying on render(view) text matching: for the "displays category name and
emoji" test use has_element?/3 or element(view,
"[data-testid=category-display]") and assert its rendered content includes
"Invoices" and "💰"; for the "shows 'No category' placeholder when nil" test
keep the has_element? call but target "[data-testid=category-display]" and check
the inner text equals "-" via element/2+render_change or has_element?/3; for the
tags assertion, replace html =~ "quarterly-report" with has_element?(view,
"[data-testid=tags-display]", "quarterly-report") or element(view,
"[data-testid=tags-display]") and check its text; for the edit link test ensure
you call has_element?(view, ~s([data-testid="edit-classification"])) (or
element(view, selector) and assert the href) so each assertion targets
category-display, tags-display, and edit-classification explicitly rather than
matching the whole page render.

114-120: Avoid regex-matching the whole header HTML here.

This check is fragile against <.header> markup changes and doesn't anchor to any stable element. Add a selector/test id for the action bar, or assert directly on the specific add-payment control instead.

Based on learnings: Applies to /test//*_live_test.{exs,ex} : Never test against raw HTML - always use element/2, has_element/2, and similar functions with selectors like assert has_element?(view, "#my-form").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs` around lines 114 - 120,
The test "does not show Add payment button in top action bar" is fragile because
it regex-matches the whole header HTML; change it to bind the LiveView (use
{:ok, view, _html} = live(conn, ~p"...") instead of {:ok, _view, html}) and
replace the regex refute with a DOM assertion such as refute has_element?(view,
"#top-action-bar .add-payment") or refute has_element?(view,
"[data-testid=\"add-payment-button\"]"); if no stable selector exists, add a
data-testid or id to the action bar or add-payment control in the template and
use that selector with element/2 / has_element?/2.
test/ksef_hub_web/live/category_live_test.exs (1)

28-31: Prefer stable selectors over raw HTML/text matches in these LiveView tests.

These cases are still coupled to copy (html =~ ...), button labels (element("button", "Delete")), and broad substrings like assert html =~ "5" even though the views already expose stable hooks like #categories, #category-form, and data-testid="category-identifier-#{cat.id}". Switching to has_element?/2 / element/2 against IDs or testids will make the suite much less brittle; the action buttons should get stable ids/testids if you need to click them.

As per coding guidelines, "Never test against raw HTML - always use element/2, has_element/2, and similar functions with selectors like assert has_element?(view, "#my-form")" and "Always reference the key element IDs you added in the LiveView templates in your tests for Phoenix.LiveViewTest functions like element/2, has_element/2, selectors, etc".

Also applies to: 37-39, 42-52, 55-60, 65-68, 110-115, 167-170, 202-207

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/category_live_test.exs` around lines 28 - 31, The
tests in category_live_test.exs are matching raw HTML/text (e.g., using html =~
"Categories", html =~ "New Category", raw counts like "5" and button text) which
is brittle; update the assertions to use LiveViewTest selectors: replace html =~
checks with assert has_element?(view, "#categories") and assert
has_element?(view, "#category-form"), and use element/2 or has_element?/2 with
stable IDs/testids for specific rows and buttons (e.g., assert
has_element?(view, " [data-testid=\"category-identifier-#{cat.id}\"]") and use
element(view, "button[data-testid=\"delete-category-#{cat.id}\"]") to click),
and change any raw count assertions to check for the specific element containing
the count via its selector instead of matching the text "5".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 119-132: The current handle_event implementations for "create_tag"
and "save" use `unless ... else` and trigger Credo warnings; refactor them to
use pattern-matched function heads or `if` so the unauthorized and authorized
flows are explicit. For example, add separate clauses for
handle_event("create_tag", %{"name" => name}, socket = %{assigns:
%{can_manage_tags: false}}) that immediately return {:noreply, put_flash(...)}
and a second clause for when can_manage_tags is true that calls
create_tag(socket, name); do the same for handle_event("save", _params, socket)
using can_set_category or can_set_tags (pattern-match the authorized case or use
an `if` for the combined condition) so the unauthorized path is its own clause
and the authorized path calls save_classification(socket).
- Around line 104-113: The handler handle_event("toggle_tag", ...) currently
trusts the client-provided "tag-id" and saves selected_tag_ids later via
save_classification/1 -> Invoices.set_invoice_tags/2; update both toggle_tag and
save_classification/1 to revalidate/sanitize tag IDs against the canonical list
in socket.assigns (e.g., socket.assigns.all_tags or company-scoped tag IDs)
before mutating state or calling Invoices.set_invoice_tags/2: on toggle, ignore
or reject tag_id values not present in the allowed set (and coerce types
consistently), and in save_classification/1 filter the selected_tag_ids set to
only include allowed IDs before passing to Invoices.set_invoice_tags/2; apply
the same whitelist validation to the other similar handler block around the
177-189 region.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 735-743: The UI currently nests a <.button> inside a <.link> (in
the render block that checks `@can_set_category/`@can_set_tags and navigates to
~p"/c/#{`@current_company.id`}/invoices/#{`@invoice.id`}/classify"), which creates
two interactive controls; remove the nested control and render a single
navigable element instead—either turn the outer <.link> into a button-styled
link (apply the button classes/variant and keep the <.icon
name="hero-pencil-square" /> and "Edit" label) or keep a <.button> and move the
navigate attribute onto it so it performs the navigation directly; update only
the fragment around the <.link>/<.button> that contains
data-testid="edit-classification" to ensure one accessible control remains.

In `@lib/ksef_hub/emoji_generator/client.ex`:
- Around line 44-62: The current Req.post result handling treats any
non-matching 200 body as a generic non-200 API error; add a dedicated clause for
{:ok, %{status: 200, body: resp_body}} that runs when the successful-status
pattern (the existing %{"content" => [%{"text" => text} | _]}) didn't match: log
a clear warning including resp_body and context.identifier (use Logger.warning
similar to other branches), return a distinct error like {:error,
{:unexpected_body, resp_body}} (or {:error, :unexpected_200_body}) so callers
can distinguish unexpected 200 payloads from real API errors; update the case
handling around Req.post/2 (the existing match that extracts text and the
fallback {:ok, %{status: status, body: resp_body}}) to include this new
200/unexpected-body branch.

In `@lib/ksef_hub/exports/csv_builder.ex`:
- Around line 120-122: The pattern matching in format_category/1 currently
checks for category.name before category.identifier, so when both exist the
identifier is ignored; reorder the clauses in format_category/1 to check for
%{category: %{identifier: id}} (is_binary(id)) before the %{category: %{name:
name}} clause so identifier is returned when present, keeping the fallback defp
format_category(_), do: "" unchanged.

In `@lib/ksef_hub/invoices/tag.ex`:
- Around line 29-32: The unique_constraint call in the Tag changeset uses the
wrong DB index name; update the unique_constraint invocation (the call to
unique_constraint/2 in the Tag changeset pipeline) to use name:
:tags_company_id_name_index so it matches the actual migration-created index
(unique_index(:tags, [:company_id, :name])) and ensure error_key: :name remains
unchanged.

In `@test/ksef_hub_web/live/category_live_test.exs`:
- Around line 191-200: The test currently asserts absence of "Generating" on
initial render and sends a synthetic {:DOWN} which doesn't exercise the real
flow; instead, drive the Auto generation flow on the LiveView (use the same
event you use to start generation — e.g., call render_submit or element(...,
"click"/"submit") against the view) so the UI enters the loading state (assert
render(view) =~ "Generating"), then simulate the failure path and assert
recovery/error UI (e.g., stub the async job to fail or send the {:DOWN, ...}
after the generation event started) and finally assert the LiveView is still
alive and shows the expected error/recovered state (keep references to view,
render, render_submit/element and the "Generating" string to locate code).
- Around line 90-105: The test currently only checks for the static hint text
from lib/ksef_hub_web/live/category_live/form.ex, so change the assertion to
verify the identifier field's actual validation feedback after submit: keep
using the same live page and form ("form#category-form") and after render_submit
assert that the rendered view contains the identifier field error by checking
the phx-feedback element for that input (selector with
phx-feedback-for="category[identifier]") or the exact error message shown for
invalid format; in short, replace assert html =~ "group:target" with an
assertion that the view includes the validation error for category[identifier]
(e.g., element("[phx-feedback-for=\"category[identifier]\"]") or the concrete
error string).

In `@test/ksef_hub/emoji_generator/client_test.exs`:
- Around line 9-145: The tests mutate Application.put_env(:ksef_hub,
:anthropic_api_key, ...) but use invalid `after` blocks so cleanup never runs;
replace those `after` blocks with proper cleanup using
ExUnit.Callbacks.on_exit/1 or wrap each mutation in try ... after inside the
test body. Concretely, in tests that call Application.put_env before invoking
KsefHub.EmojiGenerator.Client.generate_emoji (and in stubs that read the request
body), ensure you call on_exit(fn -> Application.delete_env(:ksef_hub,
:anthropic_api_key) end) (or wrap the put/delete in try ... after) so the
Application.delete_env is always executed; keep references to
Client.generate_emoji and Req.Test.stub usages intact while moving the cleanup
logic into on_exit/1 or try...after.

---

Outside diff comments:
In `@lib/ksef_hub_web/controllers/api/category_controller.ex`:
- Around line 89-91: Update the 422 error description in
lib/ksef_hub_web/controllers/api/category_controller.ex where the response map
contains the tuple 422 => {"Validation error — name is required and must be
unique within the company", "application/json", Schemas.ErrorResponse}; change
the human-readable message to state that "identifier is required and must be
unique within the company" (or similar identifier-based wording) so it matches
the schema semantics for identifier uniqueness while leaving the content-type
and Schemas.ErrorResponse reference intact.
- Around line 130-132: The 422 error mapping in CategoryController's update
response currently says "name must be unique within the company" but the unique
constraint is on identifier; update the 422 tuple string to reference
"identifier must be unique within the company" so the error description matches
the constraint (look for the 422 => {"Validation error — name must be unique
within the company", "application/json", Schemas.ErrorResponse} tuple in
lib/ksef_hub_web/controllers/api/category_controller.ex and replace the message
text accordingly).

---

Nitpick comments:
In `@lib/ksef_hub_web/live/category_live/form.ex`:
- Around line 98-103: The async task that calls EmojiGenerator.generate_emoji/1
via Task.Supervisor.async_nolink currently has no timeout, so emoji_loading can
remain true forever; add a Process.send_after/3 when starting the task to send a
timeout message (e.g. :emoji_generation_timeout) after a chosen interval, and
implement handle_info clauses to catch :emoji_generation_timeout — one that
checks %{assigns: %{emoji_loading: true}} and resets assign(:emoji_loading,
false) and sets a flash error, and a fallback
handle_info(:emoji_generation_timeout, socket) -> {:noreply, socket}; keep
references to the existing Task.Supervisor.async_nolink call,
EmojiGenerator.generate_emoji, the emoji_loading assign, and the new
:emoji_generation_timeout handler names so the change integrates with the
current flow.

In `@lib/ksef_hub_web/live/category_live/index.ex`:
- Around line 12-50: Add `@doc` documentation for the public callbacks mount/3,
handle_event/3, and render/1 in this LiveView module: place a short descriptive
`@doc` above the mount/3 function explaining it mounts the socket and assigns
page_title and streams categories for the current company, above handle_event/3
describing the "delete" event flow (ID casting, lookup, deletion, and
flash/stream updates), and above render/1 stating it returns the LiveView
rendering; include brief notes on params and return types consistent with
existing `@specs` and keep the docs concise.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 517-519: Remove the find_category/2 helper and stop assigning
`@categories`; Invoices.get_invoice_with_details/3 already preloads the :category
association, so update templates/assigns to use the preloaded invoice.category
directly (replace calls to find_category(invoice.category_id, `@categories`) or
similar with invoice.category), remove the `@categories` socket assign and any
Enum.find usage, and delete the private function find_category/2; apply the same
change for the other occurrence referenced (the block around the second
occurrence).

In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 10-20: Add `@doc` strings for all public callbacks and functions in
this LiveView module: at minimum document mount/3 and handle_params/3 (both are
public callbacks), and likewise add `@doc` for the other public functions in the
file referenced (the functions in the blocks you noted around lines 61-77 and
129-166). For each function (e.g., mount, handle_params and any public helpers
or callbacks in those ranges) add a short description of purpose, the expected
args and return shape (socket updates or {:noreply, socket}), and any important
side effects so the module adheres to the "every public function must have `@doc`"
guideline.

In `@lib/ksef_hub_web/live/tag_live/index.ex`:
- Around line 13-52: Add `@doc` annotations to the public LiveView callbacks
mount/3, handle_event/3, and render/1 describing their purpose and expected
parameters/returns; for mount/3 explain it initializes assigns (page_title and
company_id) and streams tags, for handle_event/3 describe handling the "delete"
event and possible outcomes (invalid id, not found, deletion error), and for
render/1 state that it returns the LiveView rendered template using
assigns—place each `@doc` immediately above the corresponding function definition
(mount, handle_event, render).

In `@lib/ksef_hub/emoji_generator/behaviour.ex`:
- Around line 4-9: The typespec for category_context currently marks name,
description and examples as required but the code accesses them with bracket
syntax and tests pass maps that only include identifier; update the `@type`
category_context to make these keys optional by changing the map spec to use
required(:identifier) for identifier and optional(:name),
optional(:description), optional(:examples) for the other keys (each typed as
String.t() | nil) so the typespec matches runtime usage in the behaviour module.

In `@test/ksef_hub_web/live/category_live_test.exs`:
- Around line 28-31: The tests in category_live_test.exs are matching raw
HTML/text (e.g., using html =~ "Categories", html =~ "New Category", raw counts
like "5" and button text) which is brittle; update the assertions to use
LiveViewTest selectors: replace html =~ checks with assert has_element?(view,
"#categories") and assert has_element?(view, "#category-form"), and use
element/2 or has_element?/2 with stable IDs/testids for specific rows and
buttons (e.g., assert has_element?(view, "
[data-testid=\"category-identifier-#{cat.id}\"]") and use element(view,
"button[data-testid=\"delete-category-#{cat.id}\"]") to click), and change any
raw count assertions to check for the specific element containing the count via
its selector instead of matching the text "5".

In `@test/ksef_hub_web/live/invoice_live/classify_test.exs`:
- Around line 184-187: The checked-input assertion is too broad (has_element?/2
with ~s(input[checked])) and can be triggered by unrelated inputs; update the
test after render(view) to scope the selector to the specific tag you created
(use the tag's DOM id or a unique surrounding element) and call
has_element?(view, "<scoped selector for that tag>") or use element(view,
"<scoped selector>") then assert its checked state—target the unique id/class
for the "brand-new-tag" input so only that input is matched.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs`:
- Around line 277-316: Update the tests to assert against the specific LiveView
DOM selectors instead of relying on render(view) text matching: for the
"displays category name and emoji" test use has_element?/3 or element(view,
"[data-testid=category-display]") and assert its rendered content includes
"Invoices" and "💰"; for the "shows 'No category' placeholder when nil" test
keep the has_element? call but target "[data-testid=category-display]" and check
the inner text equals "-" via element/2+render_change or has_element?/3; for the
tags assertion, replace html =~ "quarterly-report" with has_element?(view,
"[data-testid=tags-display]", "quarterly-report") or element(view,
"[data-testid=tags-display]") and check its text; for the edit link test ensure
you call has_element?(view, ~s([data-testid="edit-classification"])) (or
element(view, selector) and assert the href) so each assertion targets
category-display, tags-display, and edit-classification explicitly rather than
matching the whole page render.
- Around line 114-120: The test "does not show Add payment button in top action
bar" is fragile because it regex-matches the whole header HTML; change it to
bind the LiveView (use {:ok, view, _html} = live(conn, ~p"...") instead of {:ok,
_view, html}) and replace the regex refute with a DOM assertion such as refute
has_element?(view, "#top-action-bar .add-payment") or refute has_element?(view,
"[data-testid=\"add-payment-button\"]"); if no stable selector exists, add a
data-testid or id to the action bar or add-payment control in the template and
use that selector with element/2 / has_element?/2.

In `@test/ksef_hub_web/live/tag_live_test.exs`:
- Line 24: The test module currently mixes two LiveView specs; split the
describe blocks for TagLive.Index and TagLive.Form into two separate test files
mirroring the source layout: create
test/ksef_hub_web/live/tag_live/index_test.exs for tests targeting TagLive.Index
and test/ksef_hub_web/live/tag_live/form_test.exs for tests targeting
TagLive.Form, move the respective describe blocks and any helper setup into the
corresponding files, update module names and describe titles to reflect
TagLive.Index and TagLive.Form, and ensure any shared setup or helpers are
extracted into a common support module or imported so both new test files run
independently and follow the test/**/*_test.exs naming guideline.
- Around line 25-130: Replace raw HTML/text assertions with selector-based
LiveView helpers: use has_element?/2 and element/2 instead of html =~ or
render(view) checks. For list/show tests assert has_element?(view_or_html,
"#tags") and assert has_element?(view, ~s([data-testid="tag-name-#{tag.id}"]))
to check tag presence; for deletes use element(view,
~s([data-testid="tag-name-#{tag.id}"])) |> render_click(...) (or render_click on
the delete button selector) and then refute has_element?(view,
~s([data-testid="tag-name-#{tag.id}"])). For forms keep element("form#tag-form")
and render_submit/2, and keep assert_redirect(view, ...) and flash checks as-is
(assert_redirect/2, render_submit/1, render_click/1, has_element?/2, element/2).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6bc87c56-f300-402f-b3f9-0c17ef5a0d47

📥 Commits

Reviewing files that changed from the base of the PR and between 478447e and a7aa131.

📒 Files selected for processing (45)
  • .env.example
  • CLAUDE.md
  • cloud-run/service.yaml
  • config/config.exs
  • config/runtime.exs
  • config/test.exs
  • docs/sidecar-services.md
  • lib/ksef_hub/emoji_generator.ex
  • lib/ksef_hub/emoji_generator/behaviour.ex
  • lib/ksef_hub/emoji_generator/client.ex
  • lib/ksef_hub/exports/csv_builder.ex
  • lib/ksef_hub/invoice_classifier.ex
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/category.ex
  • lib/ksef_hub/invoices/tag.ex
  • lib/ksef_hub_web/components/invoice_components.ex
  • lib/ksef_hub_web/controllers/api/category_controller.ex
  • lib/ksef_hub_web/json_helpers.ex
  • lib/ksef_hub_web/live/category_live.ex
  • lib/ksef_hub_web/live/category_live/form.ex
  • lib/ksef_hub_web/live/category_live/index.ex
  • lib/ksef_hub_web/live/invoice_live/classify.ex
  • lib/ksef_hub_web/live/invoice_live/index.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • lib/ksef_hub_web/live/tag_live.ex
  • lib/ksef_hub_web/live/tag_live/form.ex
  • lib/ksef_hub_web/live/tag_live/index.ex
  • lib/ksef_hub_web/router.ex
  • lib/ksef_hub_web/schemas/category.ex
  • lib/ksef_hub_web/schemas/create_category_request.ex
  • lib/ksef_hub_web/schemas/update_category_request.ex
  • priv/repo/migrations/20260318090451_extend_categories.exs
  • test/ksef_hub/emoji_generator/client_test.exs
  • test/ksef_hub/emoji_generator_test.exs
  • test/ksef_hub/invoice_classifier_test.exs
  • test/ksef_hub/invoices/categories_test.exs
  • test/ksef_hub_web/controllers/api/category_controller_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
  • test/ksef_hub_web/live/category_live_test.exs
  • test/ksef_hub_web/live/invoice_live/classify_test.exs
  • test/ksef_hub_web/live/invoice_live/index_test.exs
  • test/ksef_hub_web/live/invoice_live/show_test.exs
  • test/ksef_hub_web/live/tag_live_test.exs
  • test/support/factory.ex
  • test/test_helper.exs
💤 Files with no reviewable changes (3)
  • cloud-run/service.yaml
  • lib/ksef_hub_web/live/category_live.ex
  • lib/ksef_hub_web/live/tag_live.ex

Comment thread lib/ksef_hub_web/live/invoice_live/classify.ex Outdated
Comment thread lib/ksef_hub_web/live/invoice_live/classify.ex
Comment thread lib/ksef_hub_web/live/invoice_live/show.ex
Comment on lines +44 to +62
case Req.post(req(), url: "/v1/messages", body: body) do
{:ok, %{status: 200, body: %{"content" => [%{"text" => text} | _]}}} ->
emoji = text |> String.trim() |> extract_first_emoji()

if emoji do
Logger.info("[EmojiGenerator] Generated #{emoji} for #{inspect(context.identifier)}")
{:ok, emoji}
else
Logger.warning(
"[EmojiGenerator] No emoji found in response #{inspect(text)} for #{inspect(context.identifier)}"
)

{:error, :no_emoji_in_response}
end

{:ok, %{status: status, body: resp_body}} ->
Logger.warning("[EmojiGenerator] API error status=#{status} body=#{inspect(resp_body)}")

{:error, {:api_error, status}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle unexpected 200 bodies separately from non-200 API errors.

A successful HTTP response with an empty or non-text content payload currently falls through to {:error, {:api_error, 200}}. That makes diagnostics and retry behavior misleading; add a dedicated 200/unexpected-body branch.

🩹 Suggested response split
-      {:ok, %{status: status, body: resp_body}} ->
+      {:ok, %{status: 200, body: resp_body}} ->
+        Logger.warning("[EmojiGenerator] Unexpected 200 response body=#{inspect(resp_body)}")
+        {:error, :unexpected_response}
+
+      {:ok, %{status: status, body: resp_body}} ->
         Logger.warning("[EmojiGenerator] API error status=#{status} body=#{inspect(resp_body)}")
 
         {:error, {:api_error, status}}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case Req.post(req(), url: "/v1/messages", body: body) do
{:ok, %{status: 200, body: %{"content" => [%{"text" => text} | _]}}} ->
emoji = text |> String.trim() |> extract_first_emoji()
if emoji do
Logger.info("[EmojiGenerator] Generated #{emoji} for #{inspect(context.identifier)}")
{:ok, emoji}
else
Logger.warning(
"[EmojiGenerator] No emoji found in response #{inspect(text)} for #{inspect(context.identifier)}"
)
{:error, :no_emoji_in_response}
end
{:ok, %{status: status, body: resp_body}} ->
Logger.warning("[EmojiGenerator] API error status=#{status} body=#{inspect(resp_body)}")
{:error, {:api_error, status}}
case Req.post(req(), url: "/v1/messages", body: body) do
{:ok, %{status: 200, body: %{"content" => [%{"text" => text} | _]}}} ->
emoji = text |> String.trim() |> extract_first_emoji()
if emoji do
Logger.info("[EmojiGenerator] Generated #{emoji} for #{inspect(context.identifier)}")
{:ok, emoji}
else
Logger.warning(
"[EmojiGenerator] No emoji found in response #{inspect(text)} for #{inspect(context.identifier)}"
)
{:error, :no_emoji_in_response}
end
{:ok, %{status: 200, body: resp_body}} ->
Logger.warning("[EmojiGenerator] Unexpected 200 response body=#{inspect(resp_body)}")
{:error, :unexpected_response}
{:ok, %{status: status, body: resp_body}} ->
Logger.warning("[EmojiGenerator] API error status=#{status} body=#{inspect(resp_body)}")
{:error, {:api_error, status}}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/emoji_generator/client.ex` around lines 44 - 62, The current
Req.post result handling treats any non-matching 200 body as a generic non-200
API error; add a dedicated clause for {:ok, %{status: 200, body: resp_body}}
that runs when the successful-status pattern (the existing %{"content" =>
[%{"text" => text} | _]}) didn't match: log a clear warning including resp_body
and context.identifier (use Logger.warning similar to other branches), return a
distinct error like {:error, {:unexpected_body, resp_body}} (or {:error,
:unexpected_200_body}) so callers can distinguish unexpected 200 payloads from
real API errors; update the case handling around Req.post/2 (the existing match
that extracts text and the fallback {:ok, %{status: status, body: resp_body}})
to include this new 200/unexpected-body branch.

Comment on lines 120 to 122
defp format_category(%{category: %{name: name}}) when is_binary(name), do: name
defp format_category(%{category: %{identifier: id}}) when is_binary(id), do: id
defp format_category(_), do: ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prioritize category.identifier before category.name in format_category/1.

At Line 120, the name clause matches first, so the new Line 121 identifier clause is skipped when both fields are present. This prevents identifier-based CSV output.

Suggested fix
-  defp format_category(%{category: %{name: name}}) when is_binary(name), do: name
-  defp format_category(%{category: %{identifier: id}}) when is_binary(id), do: id
+  defp format_category(%{category: %{identifier: id}}) when is_binary(id), do: id
+  defp format_category(%{category: %{name: name}}) when is_binary(name), do: name
   defp format_category(_), do: ""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defp format_category(%{category: %{name: name}}) when is_binary(name), do: name
defp format_category(%{category: %{identifier: id}}) when is_binary(id), do: id
defp format_category(_), do: ""
defp format_category(%{category: %{identifier: id}}) when is_binary(id), do: id
defp format_category(%{category: %{name: name}}) when is_binary(name), do: name
defp format_category(_), do: ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/exports/csv_builder.ex` around lines 120 - 122, The pattern
matching in format_category/1 currently checks for category.name before
category.identifier, so when both exist the identifier is ignored; reorder the
clauses in format_category/1 to check for %{category: %{identifier: id}}
(is_binary(id)) before the %{category: %{name: name}} clause so identifier is
returned when present, keeping the fallback defp format_category(_), do: ""
unchanged.

Comment thread lib/ksef_hub/invoices/tag.ex Outdated
Comment on lines +29 to +32
|> unique_constraint([:company_id, :name],
error_key: :name,
name: :tags_company_id_name_type_index
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check the actual index name in migrations for the tags table
rg -n "create.*index.*tags" --type=elixir
rg -n "unique_index.*tags" --type=elixir

Repository: appunite/appunite-ksef-ex

Length of output: 712


🏁 Script executed:

# Check all migrations that touch the tags table
rg -n "tags" priv/repo/migrations/ --type=elixir -B2 -A2

# Also check the exact line in the tags migration to confirm
cat -n priv/repo/migrations/20260223063932_create_tags.exs | sed -n '14,35p'

Repository: appunite/appunite-ksef-ex

Length of output: 2686


🏁 Script executed:

# Get the full tags table definition from the migration
cat -n priv/repo/migrations/20260223063932_create_tags.exs | sed -n '5,18p'

# Also check the Tag schema to see all columns
cat -n lib/ksef_hub/invoices/tag.ex

Repository: appunite/appunite-ksef-ex

Length of output: 1816


Constraint name must match the actual database index.

The constraint name tags_company_id_name_type_index does not match the database index created in the migration. The migration creates unique_index(:tags, [:company_id, :name]) which generates the index name tags_company_id_name_index (without _type_). There is no :type column in the tags table.

Change the constraint name to tags_company_id_name_index to match the actual database index. Otherwise, validation errors won't be properly matched to this constraint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices/tag.ex` around lines 29 - 32, The unique_constraint
call in the Tag changeset uses the wrong DB index name; update the
unique_constraint invocation (the call to unique_constraint/2 in the Tag
changeset pipeline) to use name: :tags_company_id_name_index so it matches the
actual migration-created index (unique_index(:tags, [:company_id, :name])) and
ensure error_key: :name remains unchanged.

Comment on lines +90 to 105
test "shows error for invalid identifier format", %{conn: conn, company: company} do
{:ok, view, _html} = live(conn, ~p"/c/#{company.id}/categories/new")

view
|> element("form#category-form")
|> render_submit(%{
category: %{name: "no-colon", emoji: "", description: "", sort_order: "0"}
category: %{
identifier: "no-colon",
emoji: "",
description: "",
sort_order: "0"
}
})

html = render(view)
assert html =~ "group:target"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The invalid-identifier test can pass without any validation error.

lib/ksef_hub_web/live/category_live/form.ex already renders Used by the ML classifier. Format: group:target unconditionally, so assert html =~ "group:target" does not prove the submit produced an error. Please assert on the identifier field's actual error state/message instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/category_live_test.exs` around lines 90 - 105, The
test currently only checks for the static hint text from
lib/ksef_hub_web/live/category_live/form.ex, so change the assertion to verify
the identifier field's actual validation feedback after submit: keep using the
same live page and form ("form#category-form") and after render_submit assert
that the rendered view contains the identifier field error by checking the
phx-feedback element for that input (selector with
phx-feedback-for="category[identifier]") or the exact error message shown for
invalid format; in short, replace assert html =~ "group:target" with an
assertion that the view includes the validation error for category[identifier]
(e.g., element("[phx-feedback-for=\"category[identifier]\"]") or the concrete
error string).

Comment thread test/ksef_hub_web/live/category_live_test.exs
Comment thread test/ksef_hub/emoji_generator/client_test.exs
@emilwojtaszek emilwojtaszek force-pushed the feat/classification-ui-refactor branch from a7aa131 to 9f4f423 Compare March 18, 2026 10:38
@emilwojtaszek

Copy link
Copy Markdown
Member Author

@rabbitcodeai please make full review, not sure if I applied previos suggestions, so if not please let me know

1 similar comment
@emilwojtaszek

Copy link
Copy Markdown
Member Author

@rabbitcodeai please make full review, not sure if I applied previos suggestions, so if not please let me know

@emilwojtaszek

Copy link
Copy Markdown
Member Author

@coderabbitai please make full review, not sure if I applied previos suggestions, so if not please let me know

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown

@emilwojtaszek Sure! I'll kick off a full review of PR #94 right away. I'll check all the changes and let you know if any of the previous suggestions still need to be addressed.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (3)
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

106-115: ⚠️ Potential issue | 🟠 Major

Whitelist tag IDs before updating or saving the selection.

toggle_tag trusts whatever "tag-id" the client sends, and save_classification/1 forwards that set straight into Invoices.set_invoice_tags/2. A forged LiveView event can therefore attach tags outside @all_tags and outside the current company scope.

🔒 Revalidate against the server-side tag list
   def handle_event("toggle_tag", %{"tag-id" => tag_id}, socket) do
     if socket.assigns.can_set_tags do
       current = socket.assigns.selected_tag_ids
+      allowed_tag_ids =
+        socket.assigns.all_tags
+        |> Enum.map(& &1.id)
+        |> MapSet.new()
 
-      updated =
-        if MapSet.member?(current, tag_id),
-          do: MapSet.delete(current, tag_id),
-          else: MapSet.put(current, tag_id)
-
-      {:noreply, assign(socket, :selected_tag_ids, updated)}
+      if MapSet.member?(allowed_tag_ids, tag_id) do
+        updated =
+          if MapSet.member?(current, tag_id),
+            do: MapSet.delete(current, tag_id),
+            else: MapSet.put(current, tag_id)
+
+        {:noreply, assign(socket, :selected_tag_ids, updated)}
+      else
+        {:noreply, socket}
+      end
     else
       {:noreply, put_flash(socket, :error, "You don't have permission to manage tags.")}
     end
   end
 ...
   defp save_classification(socket) do
     invoice = socket.assigns.invoice
     category_id = socket.assigns.selected_category_id
-    tag_ids = MapSet.to_list(socket.assigns.selected_tag_ids)
+    allowed_tag_ids =
+      socket.assigns.all_tags
+      |> Enum.map(& &1.id)
+      |> MapSet.new()
+
+    tag_ids =
+      socket.assigns.selected_tag_ids
+      |> MapSet.intersection(allowed_tag_ids)
+      |> MapSet.to_list()
     company = socket.assigns.current_company

Also applies to: 180-190

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 106 - 115, In
handle_event("toggle_tag", %{"tag-id" => tag_id}, socket) you must whitelist the
incoming tag_id against the server-side list of allowed tags (e.g.
socket.assigns[:all_tags] or the assign that holds company-scoped tags) before
mutating socket.assigns.selected_tag_ids; reject or ignore any tag_id not
present in that list. Do the same validation in the other handler/save path
referenced (save_classification/1) before passing selected_tag_ids to
Invoices.set_invoice_tags/2 so only permitted tag IDs are persisted. Ensure you
reference the existing symbols handle_event("toggle_tag"), selected_tag_ids,
save_classification/1 and Invoices.set_invoice_tags/2 when adding the whitelist
check.
test/ksef_hub/emoji_generator/client_test.exs (1)

9-145: ⚠️ Potential issue | 🟠 Major

Replace these after blocks with on_exit/1 or try ... after.

test ... do ... after ... end is not ExUnit cleanup syntax. The :anthropic_api_key mutation can leak into later cases and make this suite order-dependent.

🧹 Suggested cleanup pattern
+  setup do
+    on_exit(fn ->
+      Application.delete_env(:ksef_hub, :anthropic_api_key)
+    end)
+
+    :ok
+  end
+
   describe "generate_emoji/1" do
     test "returns error when API key is not configured" do
       Application.put_env(:ksef_hub, :anthropic_api_key, nil)

       assert {:error, :missing_api_key} = Client.generate_emoji(`@context`)
-    after
-      Application.delete_env(:ksef_hub, :anthropic_api_key)
     end
Does ExUnit's `test` macro support an `after` block, or should cleanup use `on_exit/1` or `try ... after` inside the test?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub/emoji_generator/client_test.exs` around lines 9 - 145, The
tests currently use invalid `after` blocks (e.g., in tests that call
Client.generate_emoji and set Application.put_env(:ksef_hub, :anthropic_api_key,
...)), which can leak environment state; replace each `after` block with either
an on_exit(fn -> Application.delete_env(:ksef_hub, :anthropic_api_key) end)
placed immediately after setting the env in the test, or wrap the test body in
try do ... after Application.delete_env(:ksef_hub, :anthropic_api_key) end;
update every test that mutates :anthropic_api_key (including those stubbing
Req.Test for KsefHub.EmojiGenerator.Client and the context-prompt tests) to use
one of these cleanup patterns so the env is always cleared even on failures.
lib/ksef_hub_web/live/invoice_live/show.ex (1)

735-743: ⚠️ Potential issue | 🟡 Minor

Render the classify navigation as a single interactive control.

<.link> wrapping <.button> nests two interactive elements. That's invalid HTML and can break focus and click behavior.

♿ Single-control version
-            <.link
-              :if={`@can_set_category` || `@can_set_tags`}
-              navigate={~p"/c/#{`@current_company.id`}/invoices/#{`@invoice.id`}/classify"}
-              data-testid="edit-classification"
-            >
-              <.button size="sm" variant="outline">
-                <.icon name="hero-pencil-square" class="size-4" /> Edit
-              </.button>
-            </.link>
+            <.button
+              :if={`@can_set_category` || `@can_set_tags`}
+              size="sm"
+              variant="outline"
+              navigate={~p"/c/#{`@current_company.id`}/invoices/#{`@invoice.id`}/classify"}
+              data-testid="edit-classification"
+            >
+              <.icon name="hero-pencil-square" class="size-4" /> Edit
+            </.button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/show.ex` around lines 735 - 743, The code
nests a <.button> inside a <.link> (in invoice_live/show.ex around the <.link
... data-testid="edit-classification">), which creates two interactive controls;
instead, make the navigation element a single control by removing the nested
<.button> and applying the button styling/attributes to the <.link> itself (or
replace the <.link> with a <button> that performs the navigation via
push_patch/push_navigate). Specifically, keep the <.link navigate={~p"..."}
data-testid="edit-classification"> and move the <.icon> and "Edit" label
directly inside it, and copy the size="sm" variant="outline" styling from the
<.button> to the <.link> (or implement push_patch in the surrounding LiveView
and use a single <button> with phx-click), ensuring only one interactive element
remains.
🧹 Nitpick comments (4)
priv/repo/migrations/20260318090451_extend_categories.exs (1)

23-23: Consider adding an index for category listing order path.

You already added uniqueness on (company_id, identifier). For list_categories/1 ordering by sort_order, identifier, a supporting composite index can reduce sorting cost as data grows.

Possible addition
   create unique_index(:categories, [:company_id, :identifier])
+  create index(:categories, [:company_id, :sort_order, :identifier])

Based on learnings: "Use database indexes on frequently queried columns".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@priv/repo/migrations/20260318090451_extend_categories.exs` at line 23, Add a
composite non-unique index to support list_categories/1 ordering by sort_order
and identifier: in the migration that currently creates
unique_index(:categories, [:company_id, :identifier]) add a line creating
index(:categories, [:company_id, :sort_order, :identifier]) (optionally with a
descriptive name like :categories_company_sort_order_identifier_index) so
queries ordering by sort_order, identifier filtered by company_id can use the
index; ensure the index creation is in the same up/forward migration and will be
removed in down/rollback if you maintain symmetric migration logic.
test/ksef_hub_web/live/tag_live_test.exs (1)

25-31: Prefer stable selectors over raw HTML/text matches in these LiveView tests.

A lot of the new assertions use html =~ ... or element("button", "Delete"), even though the templates already expose stable hooks like data-testid="tag-name-..." in lib/ksef_hub_web/live/tag_live/index.ex:67-137 and #tag-form in lib/ksef_hub_web/live/tag_live/form.ex:141-178. That makes the suite brittle to copy/style changes, and the delete test will start targeting the wrong row as soon as more than one tag is rendered.

As per coding guidelines, "Never test against raw HTML - always use element/2, has_element/2, and similar functions with selectors like assert has_element?(view, "#my-form")" and "Always reference the key element IDs you added in the LiveView templates..."

Also applies to: 33-59, 67-73, 80-81, 87-100, 112-144

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/tag_live_test.exs` around lines 25 - 31, Replace
fragile raw HTML/text assertions in the tag_live_test (e.g., lines testing
"Tags", "New Tag", "Expense", "Income" and other occurrences) with stable
selector-based assertions: use has_element?/2 or element/2 with the template IDs
and test hooks exposed in the LiveViews (for example use has_element?(view,
"#tag-form") to assert the form is present and has_element?(view,
"[data-testid=\"tag-name-<id>\"]") or a more generic data-testid selector to
assert tag rows, and use element(view, "[data-testid=\"delete-tag-<id>\"]") for
delete buttons rather than matching "Delete" text). Update all mentioned test
blocks (including the delete-row test) to target those data-testid attributes
and `#tag-form` so tests remain stable when markup or copy changes.
test/ksef_hub_web/live/category_live_test.exs (1)

188-192: Process.sleep introduces flakiness in async test.

Using Process.sleep(50) to wait for async results is fragile—timing may vary across CI environments. Consider:

  • Ensuring the mock returns synchronously so no sleep is needed
  • Using assert_receive if an async message is expected
  • Increasing timeout if sleep is truly necessary
-      # Wait for the async task result to be delivered
-      Process.sleep(50)
       html = render(view)
+      # If the mock returns synchronously, no sleep needed.
+      # Otherwise, consider using render_async(view) or assert_receive.
       assert html =~ "Failed to generate emoji"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/category_live_test.exs` around lines 188 - 192,
Replace the brittle Process.sleep(50) by making the async result deterministic:
either have the mocked emoji generation return synchronously in this test or
wait for the completion message instead of sleeping—remove Process.sleep(50),
then use assert_receive with the expected message from the async task (or
assert_receive {:emoji_failed, _} with a timeout) before calling html =
render(view) and asserting html =~ "Failed to generate emoji"; alternatively,
adjust the mock used in this test to perform the failure synchronously so
render(view) immediately contains the failure text.
lib/ksef_hub/emoji_generator/client.ex (1)

85-92: Emoji regex may miss certain sequences.

The regex \p{So} matches "Symbol, Other" Unicode category. This covers most standard emojis but may miss:

  • Keycap sequences (e.g., 1️⃣)
  • Regional indicator flags (e.g., 🇺🇸)
  • Some skin-tone modified emojis

For Claude's emoji output (typically standard single emojis), this is likely sufficient. Consider expanding if edge cases arise in production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/emoji_generator/client.ex` around lines 85 - 92, The current
extract_first_emoji/1 uses a Regex anchored on \p{So} which can miss keycap
sequences, regional indicator flags and some modifier sequences; update the
regex used in extract_first_emoji to match the full set of emoji sequences (use
Unicode emoji properties and sequence pieces) — e.g. switch to a pattern that
leverages \p{Emoji} (or \p{Emoji_Presentation}) plus optional emoji modifiers
(\p{Emoji_Modifier}), optional variation selector \x{FE0F}, ZWJ sequences (200D)
and regional indicator pairs, and explicit keycap sequences (digit + FE0F? +
20E3) so Regex.run in extract_first_emoji captures compound/ZWJ, modifier and
flag/keycap emojis rather than only \p{So}.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/category_live/form.ex`:
- Around line 11-23: The mount/3 and handle_params/3 public callbacks in this
module have `@spec` but no `@doc`; add concise `@doc` strings above each public
function (mount/3 and handle_params/3) describing purpose, params, and returned
socket tuple, matching repo style; do the same for the other public functions in
this module that currently lack `@doc` (add a one- or two-line description for
each public callback/function, keeping text consistent with existing module
docs).

In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 93-98: The redirect after successful create/update and the Cancel
handler must preserve the current tag type query param so the UI stays on the
same tab; update the push_navigate targets in the create path (the case handling
Invoices.create_tag), the update path (Invoices.update_tag), and the Cancel
event to append "?type=#{socket.assigns.active_type}" (or use
socket.assigns[:active_type]) to the
~p"/c/#{socket.assigns.current_company.id}/tags" path so the active_type tab is
retained. Ensure you update the push_navigate calls in the form component (the
handlers that call Invoices.create_tag and Invoices.update_tag and the cancel
handler) to include that query param.

In `@lib/ksef_hub/invoice_classifier.ex`:
- Around line 104-108: The code is always assigning matching_category and
matching_tag even when confidence flags are false; change the map construction
(around build_prediction_attrs, matching_category, matching_tag and
confident_category?/confident_tag?) so that category: is set to
matching_category only when confident_category? is true (otherwise nil) and tag:
is set to matching_tag only when confident_tag? is true (otherwise nil); keep
build_prediction_attrs unchanged but ensure downstream cat/tag fields are
conditional on the corresponding confident_* boolean to prevent low-confidence
auto-apply.

---

Duplicate comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 106-115: In handle_event("toggle_tag", %{"tag-id" => tag_id},
socket) you must whitelist the incoming tag_id against the server-side list of
allowed tags (e.g. socket.assigns[:all_tags] or the assign that holds
company-scoped tags) before mutating socket.assigns.selected_tag_ids; reject or
ignore any tag_id not present in that list. Do the same validation in the other
handler/save path referenced (save_classification/1) before passing
selected_tag_ids to Invoices.set_invoice_tags/2 so only permitted tag IDs are
persisted. Ensure you reference the existing symbols handle_event("toggle_tag"),
selected_tag_ids, save_classification/1 and Invoices.set_invoice_tags/2 when
adding the whitelist check.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 735-743: The code nests a <.button> inside a <.link> (in
invoice_live/show.ex around the <.link ... data-testid="edit-classification">),
which creates two interactive controls; instead, make the navigation element a
single control by removing the nested <.button> and applying the button
styling/attributes to the <.link> itself (or replace the <.link> with a <button>
that performs the navigation via push_patch/push_navigate). Specifically, keep
the <.link navigate={~p"..."} data-testid="edit-classification"> and move the
<.icon> and "Edit" label directly inside it, and copy the size="sm"
variant="outline" styling from the <.button> to the <.link> (or implement
push_patch in the surrounding LiveView and use a single <button> with
phx-click), ensuring only one interactive element remains.

In `@test/ksef_hub/emoji_generator/client_test.exs`:
- Around line 9-145: The tests currently use invalid `after` blocks (e.g., in
tests that call Client.generate_emoji and set Application.put_env(:ksef_hub,
:anthropic_api_key, ...)), which can leak environment state; replace each
`after` block with either an on_exit(fn -> Application.delete_env(:ksef_hub,
:anthropic_api_key) end) placed immediately after setting the env in the test,
or wrap the test body in try do ... after Application.delete_env(:ksef_hub,
:anthropic_api_key) end; update every test that mutates :anthropic_api_key
(including those stubbing Req.Test for KsefHub.EmojiGenerator.Client and the
context-prompt tests) to use one of these cleanup patterns so the env is always
cleared even on failures.

---

Nitpick comments:
In `@lib/ksef_hub/emoji_generator/client.ex`:
- Around line 85-92: The current extract_first_emoji/1 uses a Regex anchored on
\p{So} which can miss keycap sequences, regional indicator flags and some
modifier sequences; update the regex used in extract_first_emoji to match the
full set of emoji sequences (use Unicode emoji properties and sequence pieces) —
e.g. switch to a pattern that leverages \p{Emoji} (or \p{Emoji_Presentation})
plus optional emoji modifiers (\p{Emoji_Modifier}), optional variation selector
\x{FE0F}, ZWJ sequences (200D) and regional indicator pairs, and explicit keycap
sequences (digit + FE0F? + 20E3) so Regex.run in extract_first_emoji captures
compound/ZWJ, modifier and flag/keycap emojis rather than only \p{So}.

In `@priv/repo/migrations/20260318090451_extend_categories.exs`:
- Line 23: Add a composite non-unique index to support list_categories/1
ordering by sort_order and identifier: in the migration that currently creates
unique_index(:categories, [:company_id, :identifier]) add a line creating
index(:categories, [:company_id, :sort_order, :identifier]) (optionally with a
descriptive name like :categories_company_sort_order_identifier_index) so
queries ordering by sort_order, identifier filtered by company_id can use the
index; ensure the index creation is in the same up/forward migration and will be
removed in down/rollback if you maintain symmetric migration logic.

In `@test/ksef_hub_web/live/category_live_test.exs`:
- Around line 188-192: Replace the brittle Process.sleep(50) by making the async
result deterministic: either have the mocked emoji generation return
synchronously in this test or wait for the completion message instead of
sleeping—remove Process.sleep(50), then use assert_receive with the expected
message from the async task (or assert_receive {:emoji_failed, _} with a
timeout) before calling html = render(view) and asserting html =~ "Failed to
generate emoji"; alternatively, adjust the mock used in this test to perform the
failure synchronously so render(view) immediately contains the failure text.

In `@test/ksef_hub_web/live/tag_live_test.exs`:
- Around line 25-31: Replace fragile raw HTML/text assertions in the
tag_live_test (e.g., lines testing "Tags", "New Tag", "Expense", "Income" and
other occurrences) with stable selector-based assertions: use has_element?/2 or
element/2 with the template IDs and test hooks exposed in the LiveViews (for
example use has_element?(view, "#tag-form") to assert the form is present and
has_element?(view, "[data-testid=\"tag-name-<id>\"]") or a more generic
data-testid selector to assert tag rows, and use element(view,
"[data-testid=\"delete-tag-<id>\"]") for delete buttons rather than matching
"Delete" text). Update all mentioned test blocks (including the delete-row test)
to target those data-testid attributes and `#tag-form` so tests remain stable when
markup or copy changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f3fc5569-739b-4974-be1b-841e40e882a0

📥 Commits

Reviewing files that changed from the base of the PR and between a7aa131 and 9f4f423.

📒 Files selected for processing (45)
  • .env.example
  • CLAUDE.md
  • cloud-run/service.yaml
  • config/config.exs
  • config/runtime.exs
  • config/test.exs
  • docs/sidecar-services.md
  • lib/ksef_hub/emoji_generator.ex
  • lib/ksef_hub/emoji_generator/behaviour.ex
  • lib/ksef_hub/emoji_generator/client.ex
  • lib/ksef_hub/exports/csv_builder.ex
  • lib/ksef_hub/invoice_classifier.ex
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/category.ex
  • lib/ksef_hub_web/components/invoice_components.ex
  • lib/ksef_hub_web/controllers/api/category_controller.ex
  • lib/ksef_hub_web/json_helpers.ex
  • lib/ksef_hub_web/live/category_live.ex
  • lib/ksef_hub_web/live/category_live/form.ex
  • lib/ksef_hub_web/live/category_live/index.ex
  • lib/ksef_hub_web/live/invoice_live/classify.ex
  • lib/ksef_hub_web/live/invoice_live/index.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • lib/ksef_hub_web/live/tag_live.ex
  • lib/ksef_hub_web/live/tag_live/form.ex
  • lib/ksef_hub_web/live/tag_live/index.ex
  • lib/ksef_hub_web/router.ex
  • lib/ksef_hub_web/schemas/category.ex
  • lib/ksef_hub_web/schemas/create_category_request.ex
  • lib/ksef_hub_web/schemas/update_category_request.ex
  • lib/ksef_hub_web/schemas/update_tag_request.ex
  • priv/repo/migrations/20260318090451_extend_categories.exs
  • test/ksef_hub/emoji_generator/client_test.exs
  • test/ksef_hub/emoji_generator_test.exs
  • test/ksef_hub/invoice_classifier_test.exs
  • test/ksef_hub/invoices/categories_test.exs
  • test/ksef_hub_web/controllers/api/category_controller_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
  • test/ksef_hub_web/live/category_live_test.exs
  • test/ksef_hub_web/live/invoice_live/classify_test.exs
  • test/ksef_hub_web/live/invoice_live/index_test.exs
  • test/ksef_hub_web/live/invoice_live/show_test.exs
  • test/ksef_hub_web/live/tag_live_test.exs
  • test/support/factory.ex
  • test/test_helper.exs
💤 Files with no reviewable changes (3)
  • lib/ksef_hub_web/live/category_live.ex
  • lib/ksef_hub_web/live/tag_live.ex
  • cloud-run/service.yaml
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (13)
  • docs/sidecar-services.md
  • test/test_helper.exs
  • lib/ksef_hub_web/json_helpers.ex
  • lib/ksef_hub_web/live/invoice_live/index.ex
  • lib/ksef_hub_web/components/invoice_components.ex
  • test/ksef_hub/emoji_generator_test.exs
  • lib/ksef_hub/emoji_generator/behaviour.ex
  • config/config.exs
  • lib/ksef_hub_web/schemas/create_category_request.ex
  • config/test.exs
  • lib/ksef_hub/invoices/category.ex
  • test/support/factory.ex
  • test/ksef_hub/invoice_classifier_test.exs

Comment thread lib/ksef_hub_web/live/category_live/form.ex
Comment thread lib/ksef_hub_web/live/tag_live/form.ex Outdated
Comment thread lib/ksef_hub/invoice_classifier.ex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

106-115: ⚠️ Potential issue | 🟠 Major

The earlier tag-id whitelist gap is still open.

"toggle_tag" still trusts raw "tag-id" from the client, and save_classification/1 forwards the accumulated set straight into Invoices.set_invoice_tags/2. A forged event can keep ids that never came from the company-scoped @all_tags. Whitelist against socket.assigns.all_tags before mutating selected_tag_ids and again before persisting.

Based on learnings: Applies to **/*.live.ex : Validate all state changes in LiveView events.

Also applies to: 183-189

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 106 - 115, The
"toggle_tag" event handler currently trusts the raw "tag-id" from the client;
validate the incoming tag_id against the server-side whitelist in
socket.assigns.all_tags before mutating socket.assigns.selected_tag_ids in
handle_event("toggle_tag") (only allow toggle if tag_id exists in all_tags), and
also enforce the same whitelist in save_classification/1 before passing tags to
Invoices.set_invoice_tags/2 (filter selected_tag_ids to include only ids present
in socket.assigns.all_tags) so forged events cannot add out-of-scope tag ids.
🧹 Nitpick comments (1)
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

83-85: Complete the public callback docs/spec coverage.

mount/3 is documented/spec'ed, but handle_event/3 has no @doc, and render/1 is missing both @doc and @spec. The repo rules for lib/**/*.ex require those callback declarations too.

As per coding guidelines: lib/**/*.ex: Every public function must have @doc documentation in Elixir. Every function (public and private) must have @spec type specification in Elixir.

Also applies to: 212-213

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 83 - 85, Add
missing public docs and specs: add an `@doc` for handle_event/3 describing
accepted event names and payload shape and an `@spec` handle_event(String.t(),
map(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()};
add both `@doc` and `@spec` for render/1 (e.g., `@spec`
render(Phoenix.LiveView.Socket.t() | map()) :: Phoenix.HTML.Rendered.t() or the
appropriate return type used in this module); also ensure any other public
callbacks around lines mentioned (e.g., the functions at 212-213) have `@doc` and
all functions (public and private) include `@specs` per project rules.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 121-123: The authorization check is too broad: inside handle_event
"create_tag" you call create_tag and later save_classification/1 while only one
permission may be intended; update the save path so save_classification/1 only
persists category changes when socket.assigns.can_set_category is true and only
persists tag changes when socket.assigns.can_set_tags is true. Constrain
create_tag/2 so it does not auto-select or persist tags unless can_set_tags is
true, and similarly guard any category setter calls (e.g., in
save_classification/1 and related handlers) with
socket.assigns.can_set_category; apply the same per-permission checks for the
other affected handlers mentioned (lines ~129-131, ~157-166, ~186-189) to ensure
persistence decisions are made at the persistence boundary, not based on earlier
role bits.

---

Duplicate comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 106-115: The "toggle_tag" event handler currently trusts the raw
"tag-id" from the client; validate the incoming tag_id against the server-side
whitelist in socket.assigns.all_tags before mutating
socket.assigns.selected_tag_ids in handle_event("toggle_tag") (only allow toggle
if tag_id exists in all_tags), and also enforce the same whitelist in
save_classification/1 before passing tags to Invoices.set_invoice_tags/2 (filter
selected_tag_ids to include only ids present in socket.assigns.all_tags) so
forged events cannot add out-of-scope tag ids.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 83-85: Add missing public docs and specs: add an `@doc` for
handle_event/3 describing accepted event names and payload shape and an `@spec`
handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: {:noreply,
Phoenix.LiveView.Socket.t()}; add both `@doc` and `@spec` for render/1 (e.g., `@spec`
render(Phoenix.LiveView.Socket.t() | map()) :: Phoenix.HTML.Rendered.t() or the
appropriate return type used in this module); also ensure any other public
callbacks around lines mentioned (e.g., the functions at 212-213) have `@doc` and
all functions (public and private) include `@specs` per project rules.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f5a412d7-0059-4849-8cf5-187c3c76afbd

📥 Commits

Reviewing files that changed from the base of the PR and between 9f4f423 and f958b62.

📒 Files selected for processing (1)
  • lib/ksef_hub_web/live/invoice_live/classify.ex

Comment thread lib/ksef_hub_web/live/invoice_live/classify.ex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (8)
test/ksef_hub/emoji_generator/client_test.exs (1)

86-98: Consider using the updated conn from read_body/1.

After Plug.Conn.read_body/1, the returned conn should typically be used for subsequent operations. Here, the updated conn is ignored (_conn) and the original conn is passed to Req.Test.json/2. This may work fine in the Req.Test stub context since it manages the response lifecycle, but using the returned conn would be more idiomatic Plug usage.

♻️ Proposed refactor
      Req.Test.stub(KsefHub.EmojiGenerator.Client, fn conn ->
-       {:ok, body, _conn} = Plug.Conn.read_body(conn)
+       {:ok, body, conn} = Plug.Conn.read_body(conn)
        decoded = Jason.decode!(body)
        [%{"content" => prompt}] = decoded["messages"]

        assert prompt =~ "Identifier: finance:invoices"
        assert prompt =~ "Name: Invoices"
        assert prompt =~ "Description: Invoice processing"
        assert prompt =~ "Examples: Monthly recurring"

        Req.Test.json(conn, %{
          "content" => [%{"type" => "text", "text" => "💰"}]
        })
      end)

The same pattern appears in lines 114-127.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub/emoji_generator/client_test.exs` around lines 86 - 98, The stub
reads the request body but ignores the updated conn returned by
Plug.Conn.read_body/1 (bound as _conn) and then calls Req.Test.json/2 with the
old conn; update the code to capture and use the returned conn (e.g., change
_conn to new_conn and pass new_conn to Req.Test.json/2) so subsequent operations
use the updated connection; apply the same fix for the second stub instance
around the later block (the other Plug.Conn.read_body/1 and Req.Test.json/2
pair).
test/ksef_hub/invoice_classifier_test.exs (1)

370-378: Minor: @spec return type doesn't match actual return.

The @spec declares :: :ok, but Mox.expect/3 chains return the mock module. Since callers don't use the return value, this is cosmetic, but for accuracy:

📝 Suggested fix
-  `@spec` expect_predictions(keyword()) :: :ok
+  `@spec` expect_predictions(keyword()) :: module()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub/invoice_classifier_test.exs` around lines 370 - 378, The `@spec`
for expect_predictions is incorrect (declares :: :ok) because the Mox.expect
call chain returns the mock module rather than :ok; update the spec on
expect_predictions to reflect the actual return (for example :: module() or ::
any()), or remove the `@spec` entirely; locate the expect_predictions function and
adjust its `@spec` accordingly (referencing KsefHub.InvoiceClassifier.Mock and the
expect_predictions/1 function).
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

193-199: Consider scoping persistence calls to the user's actual permissions.

While the UI events (select_category, toggle_tag) are properly guarded, save_classification/1 unconditionally calls both set_invoice_category and set_invoice_tags. A user with only can_set_tags still triggers the category setter (though with the unchanged value).

For defense-in-depth, scope each persistence call to its corresponding permission:

🛡️ Suggested approach
     result =
       Invoices.with_manual_prediction(invoice, fn ->
-        with {:ok, updated} <- Invoices.set_invoice_category(invoice, category_id),
-             {:ok, _tags} <- Invoices.set_invoice_tags(updated.id, tag_ids) do
-          {:ok, updated}
+        with {:ok, updated} <- maybe_set_category(socket, invoice, category_id),
+             {:ok, _tags} <- maybe_set_tags(socket, updated, tag_ids) do
+          {:ok, Invoices.get_invoice!(company.id, updated.id)}
         end
       end)
+
+  defp maybe_set_category(socket, invoice, category_id) do
+    if socket.assigns.can_set_category,
+      do: Invoices.set_invoice_category(invoice, category_id),
+      else: {:ok, invoice}
+  end
+
+  defp maybe_set_tags(socket, invoice, tag_ids) do
+    if socket.assigns.can_set_tags,
+      do: Invoices.set_invoice_tags(invoice.id, tag_ids),
+      else: {:ok, []}
+  end

Based on learnings: Applies to **/{*controller.ex,*live.ex} : Check authorization in controllers and LiveViews.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 193 - 199, The
save_classification/1 flow currently calls Invoices.set_invoice_category and
Invoices.set_invoice_tags unconditionally inside
Invoices.with_manual_prediction; change it so each persistence call is executed
only when the current user has the corresponding permission: call
Invoices.set_invoice_category(invoice, category_id) only when the user can set
categories, and call Invoices.set_invoice_tags(updated.id, tag_ids) only when
the user can_set_tags; preserve the surrounding Invoices.with_manual_prediction
usage and ensure the final {:ok, updated} return path still returns the
up-to-date invoice after whichever allowed updates ran.
lib/ksef_hub_web/live/invoice_live/show.ex (1)

988-991: Consider browser support for field-sizing: content.

This CSS property is relatively new (available in Chrome 123+, Firefox 123+, Safari 17.4+). The oninput JavaScript fallback provides good progressive enhancement, but the field-sizing: content declaration will be ignored in older browsers, leaving the textarea at a fixed height until user input triggers the JS resize.

This is acceptable for most use cases—just noting for awareness.

Also applies to: 1018-1022

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/show.ex` around lines 988 - 991, The
inline textarea style uses the new CSS property "field-sizing: content" which
older browsers ignore; update the markup around the textarea (the element with
the inline style and the oninput handler) to provide a safe fallback by adding
an initial height behavior (e.g., height: auto and a sensible min-height) or a
CSS class that applies those fallback rules, keep the existing oninput
auto-resize JS as-is, and apply the same change to the other textarea instance
noted in the diff so non-supporting browsers don't render a fixed-height
textarea before user input.
lib/ksef_hub_web/live/tag_live/form.ex (4)

140-142: Missing @doc for public callback render/1.

   `@impl` true
+  `@doc` """
+  Renders the tag form.
+  """
   `@spec` render(map()) :: Phoenix.LiveView.Rendered.t()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 140 - 142, Add a short
`@doc` above the public callback function render/1 explaining its purpose and
contract (what assigns is and what the function returns), e.g., a one- or
two-line description mentioning it renders the LiveView form given assigns and
returns a Phoenix.LiveView.Rendered struct; place the `@doc` directly above the
`@impl` true and def render(assigns) to document render/1 for tools and Dialyzer.

64-80: Missing @doc for public callback handle_event/3.

Add documentation for the event handlers.

   `@impl` true
+  `@doc` """
+  Handles form validation and save events.
+  """
   `@spec` handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) ::
           {:noreply, Phoenix.LiveView.Socket.t()}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 64 - 80, Add a `@doc`
string above the public callback handle_event/3 describing the supported events
("validate" and "save"), the expected params shape (map with "tag" key), and the
return shape ({:noreply, Phoenix.LiveView.Socket.t()}), placing the doc
immediately above the `@impl` true for handle_event/3 so both pattern clauses (the
"validate" clause that builds a changeset and assigns form, and the "save"
clause that delegates to create_tag/2 or update_tag/3) are covered.

10-14: Missing @doc for public callback mount/3.

Per coding guidelines, every public function must have @doc documentation. Add a brief description of the mount callback behavior.

   `@impl` true
+  `@doc` """
+  Initializes the LiveView socket.
+  """
   `@spec` mount(map(), map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
   def mount(_params, _session, socket) do
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 10 - 14, The public
LiveView callback mount/3 (def mount(_params, _session, socket) in TagLive.Form)
is missing `@doc`; add a short `@doc` string immediately above mount/3 that states
it initializes the socket for the form LiveView, describes the parameters
(_params, _session, socket) and documents the returned value {:ok, socket} (or
any socket assigns initialized), e.g., a one- or two-sentence description of
what state/assigns are set by mount/3 for this LiveView.

16-21: Missing @doc for public callback handle_params/3.

   `@impl` true
+  `@doc` """
+  Handles route parameters and applies the appropriate action (`:new` or `:edit`).
+  """
   `@spec` handle_params(map(), String.t(), Phoenix.LiveView.Socket.t()) ::
           {:noreply, Phoenix.LiveView.Socket.t()}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 16 - 21, Add a
descriptive `@doc` for the public LiveView callback handle_params/3 that explains
its purpose, expected parameters (params map, _uri string,
Phoenix.LiveView.Socket), and the return value {:noreply,
Phoenix.LiveView.Socket.t()}; update the module containing handle_params/3
(which calls apply_action(socket, socket.assigns.live_action, params)) to
include this `@doc` immediately above the `@spec/`@impl so the public API is
documented for callers and generated docs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/category_live/form.ex`:
- Around line 69-72: When rebuilding the changeset after the async emoji result
(the same place changeset_for(socket, params) is used and where assign(socket,
form: to_form(...)) is called), preserve the existing validation state by
ensuring the new changeset keeps action: :validate instead of dropping it;
update the code that creates the fresh changeset (and the similar block around
lines 118-129) to set the changeset's action to :validate (e.g., set
Map.put(changeset, :action, :validate) or build it via %{changeset_for(...) |
action: :validate}) before passing it to to_form and assign.
- Around line 197-205: current_form_params/1 is converting the sort_order field
into a string and later atomize_params/1 normalizes blanks/invalids to 0, which
silently changes nil/invalid values; instead, preserve the raw or nil value for
:sort_order so Category.changeset/2 can perform casting/validation. Update
current_form_params/1 (and the similar block at lines 208-223) to special-case
the :sort_order field: do not coerce blank or non-numeric values to "0" or "0"
string—pass through the original form value (or nil) for
Atom.to_string("sort_order") and let atomize_params/1 or Category.changeset/2
handle validation. Ensure you reference the fields list (~w(identifier name
emoji description examples sort_order)a) and adjust only handling of the
:sort_order mapping so other fields remain stringified as before.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 189-191: The three expressions (allowed_tag_ids, tag_ids, company)
are formatted on a long line that fails mix format; split the long pipeline into
multiple shorter lines so each assignment is within line length limits.
Specifically, rewrite the computation for allowed_tag_ids using MapSet.new/2
with a short anonymous fun, then compute tag_ids by piping
socket.assigns.selected_tag_ids into MapSet.intersection(allowed_tag_ids) and
then into MapSet.to_list() on its own line(s), and leave company =
socket.assigns.current_company on its own line; update the assignments for
allowed_tag_ids and tag_ids (referencing allowed_tag_ids, tag_ids,
socket.assigns.selected_tag_ids, MapSet.intersection, MapSet.to_list) to ensure
each line is under the formatter width.

In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 93-102: The case block handling Invoices.create_tag has formatting
issues; run mix format or reformat the case expression so the pipeline operators
are aligned and the |> calls on the success branch are on their own lines (the
branch that calls put_flash and push_navigate) and the error branch assigns the
form via assign(socket, form: to_form(changeset, as: :tag)); specifically
reformat the block around the Invoices.create_tag call and the {:ok, _tag} /
{:error, %Ecto.Changeset{} = changeset} branches to match mix formatter rules.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 193-199: The save_classification/1 flow currently calls
Invoices.set_invoice_category and Invoices.set_invoice_tags unconditionally
inside Invoices.with_manual_prediction; change it so each persistence call is
executed only when the current user has the corresponding permission: call
Invoices.set_invoice_category(invoice, category_id) only when the user can set
categories, and call Invoices.set_invoice_tags(updated.id, tag_ids) only when
the user can_set_tags; preserve the surrounding Invoices.with_manual_prediction
usage and ensure the final {:ok, updated} return path still returns the
up-to-date invoice after whichever allowed updates ran.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 988-991: The inline textarea style uses the new CSS property
"field-sizing: content" which older browsers ignore; update the markup around
the textarea (the element with the inline style and the oninput handler) to
provide a safe fallback by adding an initial height behavior (e.g., height: auto
and a sensible min-height) or a CSS class that applies those fallback rules,
keep the existing oninput auto-resize JS as-is, and apply the same change to the
other textarea instance noted in the diff so non-supporting browsers don't
render a fixed-height textarea before user input.

In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 140-142: Add a short `@doc` above the public callback function
render/1 explaining its purpose and contract (what assigns is and what the
function returns), e.g., a one- or two-line description mentioning it renders
the LiveView form given assigns and returns a Phoenix.LiveView.Rendered struct;
place the `@doc` directly above the `@impl` true and def render(assigns) to document
render/1 for tools and Dialyzer.
- Around line 64-80: Add a `@doc` string above the public callback handle_event/3
describing the supported events ("validate" and "save"), the expected params
shape (map with "tag" key), and the return shape ({:noreply,
Phoenix.LiveView.Socket.t()}), placing the doc immediately above the `@impl` true
for handle_event/3 so both pattern clauses (the "validate" clause that builds a
changeset and assigns form, and the "save" clause that delegates to create_tag/2
or update_tag/3) are covered.
- Around line 10-14: The public LiveView callback mount/3 (def mount(_params,
_session, socket) in TagLive.Form) is missing `@doc`; add a short `@doc` string
immediately above mount/3 that states it initializes the socket for the form
LiveView, describes the parameters (_params, _session, socket) and documents the
returned value {:ok, socket} (or any socket assigns initialized), e.g., a one-
or two-sentence description of what state/assigns are set by mount/3 for this
LiveView.
- Around line 16-21: Add a descriptive `@doc` for the public LiveView callback
handle_params/3 that explains its purpose, expected parameters (params map, _uri
string, Phoenix.LiveView.Socket), and the return value {:noreply,
Phoenix.LiveView.Socket.t()}; update the module containing handle_params/3
(which calls apply_action(socket, socket.assigns.live_action, params)) to
include this `@doc` immediately above the `@spec/`@impl so the public API is
documented for callers and generated docs.

In `@test/ksef_hub/emoji_generator/client_test.exs`:
- Around line 86-98: The stub reads the request body but ignores the updated
conn returned by Plug.Conn.read_body/1 (bound as _conn) and then calls
Req.Test.json/2 with the old conn; update the code to capture and use the
returned conn (e.g., change _conn to new_conn and pass new_conn to
Req.Test.json/2) so subsequent operations use the updated connection; apply the
same fix for the second stub instance around the later block (the other
Plug.Conn.read_body/1 and Req.Test.json/2 pair).

In `@test/ksef_hub/invoice_classifier_test.exs`:
- Around line 370-378: The `@spec` for expect_predictions is incorrect (declares
:: :ok) because the Mox.expect call chain returns the mock module rather than
:ok; update the spec on expect_predictions to reflect the actual return (for
example :: module() or :: any()), or remove the `@spec` entirely; locate the
expect_predictions function and adjust its `@spec` accordingly (referencing
KsefHub.InvoiceClassifier.Mock and the expect_predictions/1 function).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: be8ab229-f99b-4a28-9af4-41ffdcf0f386

📥 Commits

Reviewing files that changed from the base of the PR and between f958b62 and d451552.

📒 Files selected for processing (8)
  • lib/ksef_hub/invoice_classifier.ex
  • lib/ksef_hub_web/live/category_live/form.ex
  • lib/ksef_hub_web/live/invoice_live/classify.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • lib/ksef_hub_web/live/tag_live/form.ex
  • test/ksef_hub/emoji_generator/client_test.exs
  • test/ksef_hub/invoice_classifier_test.exs
  • test/ksef_hub_web/live/tag_live_test.exs

Comment on lines +69 to +72
changeset =
%{changeset_for(socket, params) | action: :validate}

{:noreply, assign(socket, form: to_form(changeset, as: :category))}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve the validation state when applying the generated emoji.

The "validate" path explicitly sets action: :validate, but the async success path rebuilds a fresh changeset and drops that action. As a result, any validation feedback already shown to the user disappears as soon as the emoji result lands.

💡 Suggested fix
-    changeset = changeset_for(socket, params)
+    current_action = socket.assigns.form.source.action
+    changeset = %{changeset_for(socket, params) | action: current_action}

Also applies to: 118-129

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/category_live/form.ex` around lines 69 - 72, When
rebuilding the changeset after the async emoji result (the same place
changeset_for(socket, params) is used and where assign(socket, form:
to_form(...)) is called), preserve the existing validation state by ensuring the
new changeset keeps action: :validate instead of dropping it; update the code
that creates the fresh changeset (and the similar block around lines 118-129) to
set the changeset's action to :validate (e.g., set Map.put(changeset, :action,
:validate) or build it via %{changeset_for(...) | action: :validate}) before
passing it to to_form and assign.

Comment on lines +197 to +205
@spec current_form_params(Phoenix.LiveView.Socket.t()) :: map()
defp current_form_params(socket) do
form = socket.assigns.form
fields = ~w(identifier name emoji description examples sort_order)a

Map.new(fields, fn field ->
value = form[field].value
{Atom.to_string(field), if(is_binary(value), do: value, else: to_string(value))}
end)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't coerce blank or invalid sort_order values to 0.

current_form_params/1 turns a missing sort order into "", and atomize_params/1 then normalizes every blank/invalid value to 0. That means editing a category with no sort order and saving it will silently rewrite nil to 0, and invalid input never reaches Category.changeset/2 as a cast error. Since lib/ksef_hub/invoices/category.ex already casts :sort_order, this helper should pass the raw value through (or preserve nil) instead of forcing a fallback.

💡 Suggested fix
 defp atomize_params(params) do
-  sort_order =
-    case Integer.parse(params["sort_order"] || "0") do
-      {n, ""} -> n
-      _ -> 0
-    end
-
   %{
     identifier: params["identifier"] || "",
     name: blank_to_nil(params["name"]),
     emoji: blank_to_nil(params["emoji"]),
     description: blank_to_nil(params["description"]),
     examples: blank_to_nil(params["examples"]),
-    sort_order: sort_order
+    sort_order: blank_to_nil(params["sort_order"])
   }
 end

Also applies to: 208-223

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/category_live/form.ex` around lines 197 - 205,
current_form_params/1 is converting the sort_order field into a string and later
atomize_params/1 normalizes blanks/invalids to 0, which silently changes
nil/invalid values; instead, preserve the raw or nil value for :sort_order so
Category.changeset/2 can perform casting/validation. Update
current_form_params/1 (and the similar block at lines 208-223) to special-case
the :sort_order field: do not coerce blank or non-numeric values to "0" or "0"
string—pass through the original form value (or nil) for
Atom.to_string("sort_order") and let atomize_params/1 or Category.changeset/2
handle validation. Ensure you reference the fields list (~w(identifier name
emoji description examples sort_order)a) and adjust only handling of the
:sort_order mapping so other fields remain stringified as before.

Comment thread lib/ksef_hub_web/live/invoice_live/classify.ex
Comment thread lib/ksef_hub_web/live/tag_live/form.ex

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
lib/ksef_hub_web/live/tag_live/form.ex (1)

39-59: ⚠️ Potential issue | 🟡 Minor

Preserve tag type on the not-found redirect path.

When edit fails with :not_found, navigation drops ?type=..., so users can be sent back to the wrong tab.

↩️ Suggested fix
-defp apply_action(socket, :edit, %{"id" => id}) do
+defp apply_action(socket, :edit, %{"id" => id} = params) do
   company_id = socket.assigns.current_company.id
+  requested_type = parse_tag_type(params["type"])

   case Invoices.get_tag(company_id, id) do
     {:ok, tag} ->
       changeset = Tag.changeset(tag, %{})

       socket
       |> assign(
         page_title: "Edit Tag",
         tag: tag,
         company_id: company_id,
         tag_type: tag.type
       )
       |> assign(form: to_form(changeset, as: :tag))

     {:error, :not_found} ->
       socket
       |> put_flash(:error, "Tag not found.")
-      |> push_navigate(to: ~p"/c/#{company_id}/tags")
+      |> push_navigate(to: ~p"/c/#{company_id}/tags?type=#{requested_type}")
   end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/tag_live/form.ex` around lines 39 - 59, When handling
the :not_found branch in apply_action/3 for :edit, include the current tag type
from socket assigns in the redirect so the UI stays on the same tab; change the
push_navigate call that currently uses ~p"/c/#{company_id}/tags" to include the
type query param (e.g. use socket.assigns.tag_type or a fallback and build the
path like ~p"/c/#{company_id}/tags?type=#{tag_type}") so the ?type=... is
preserved when navigating away on error.
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

197-203: ⚠️ Potential issue | 🟠 Major

Enforce individual permissions when persisting classification.

The save handler allows entry if either can_set_category or can_set_tags is true, but save_classification/1 unconditionally executes both setters. A user with only tag permissions can modify the category, and vice versa.

🔒 Proposed fix to respect individual permissions
   defp save_classification(socket) do
     invoice = socket.assigns.invoice
-    category_id = socket.assigns.selected_category_id
     allowed_tag_ids = MapSet.new(socket.assigns.all_tags, & &1.id)

     tag_ids =
-      socket.assigns.selected_tag_ids |> MapSet.intersection(allowed_tag_ids) |> MapSet.to_list()
+      socket.assigns.selected_tag_ids
+      |> MapSet.intersection(allowed_tag_ids)
+      |> MapSet.to_list()

     company = socket.assigns.current_company
+    can_set_category = socket.assigns.can_set_category
+    can_set_tags = socket.assigns.can_set_tags

     result =
       Invoices.with_manual_prediction(invoice, fn ->
-        with {:ok, updated} <- Invoices.set_invoice_category(invoice, category_id),
-             {:ok, _tags} <- Invoices.set_invoice_tags(updated.id, tag_ids) do
-          {:ok, updated}
+        with {:ok, updated} <- maybe_set_category(invoice, socket.assigns.selected_category_id, can_set_category),
+             {:ok, _tags} <- maybe_set_tags(updated.id, tag_ids, can_set_tags) do
+          {:ok, updated}
         end
       end)

Add helper functions:

`@spec` maybe_set_category(Invoice.t(), String.t() | nil, boolean()) ::
        {:ok, Invoice.t()} | {:error, term()}
defp maybe_set_category(invoice, _category_id, false), do: {:ok, invoice}
defp maybe_set_category(invoice, category_id, true), do: Invoices.set_invoice_category(invoice, category_id)

`@spec` maybe_set_tags(String.t(), [String.t()], boolean()) ::
        {:ok, [Tag.t()]} | {:error, term()}
defp maybe_set_tags(_invoice_id, _tag_ids, false), do: {:ok, []}
defp maybe_set_tags(invoice_id, tag_ids, true), do: Invoices.set_invoice_tags(invoice_id, tag_ids)

Based on learnings: Applies to **/{*controller.ex,*live.ex} : Check authorization in controllers and LiveViews.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 197 - 203, The
current save path inside the Invoices.with_manual_prediction block
unconditionally calls Invoices.set_invoice_category and
Invoices.set_invoice_tags so a user with only one permission can change both;
create helper functions maybe_set_category(invoice, category_id,
can_set_category) and maybe_set_tags(invoice_id, tag_ids, can_set_tags) (as
described) and replace the direct calls in the anonymous function passed to
Invoices.with_manual_prediction (the code that currently does with {:ok,
updated} <- Invoices.set_invoice_category(...), {:ok, _tags} <-
Invoices.set_invoice_tags(...)) to instead call maybe_set_category and then
maybe_set_tags, passing the current can_set_category and can_set_tags booleans
so each setter runs only when that specific permission is true and otherwise
returns {:ok, unchanged}.
🧹 Nitpick comments (1)
lib/ksef_hub_web/live/invoice_live/classify.ex (1)

192-193: Split the long pipeline for readability.

The pipeline exceeds line length limits and should be broken into multiple lines per Elixir formatting conventions.

♻️ Proposed fix
     tag_ids =
-      socket.assigns.selected_tag_ids |> MapSet.intersection(allowed_tag_ids) |> MapSet.to_list()
+      socket.assigns.selected_tag_ids
+      |> MapSet.intersection(allowed_tag_ids)
+      |> MapSet.to_list()

As per coding guidelines: Break long pipes into multiple assignments for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/classify.ex` around lines 192 - 193, The
one-line pipeline assigning tag_ids is too long; split it into multiple steps
for readability by first binding the selected set (e.g., selected_tags =
socket.assigns.selected_tag_ids), then computing the intersection (e.g.,
intersected = MapSet.intersection(selected_tags, allowed_tag_ids)), and finally
converting to a list (e.g., tag_ids = MapSet.to_list(intersected)); update
references to use these intermediate variables and keep each function call on
its own line.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 10-20: Add short `@doc` one-line annotations for each public
LiveView callback shown (mount/3, handle_params/3) and for the other public
callbacks mentioned (handle_event/3 and render/1) so every public function has
documentation; place a concise descriptive `@doc` string immediately above each
function definition (preserve existing `@impl` and `@spec` lines) summarizing the
function's purpose and inputs/behavior. Ensure the `@doc` strings are brief (one
sentence), cover mount/3, handle_params/3, handle_event/3, and render/1, and
likewise add `@doc` to the other public functions referenced around lines 64-80
and 144-146.

---

Duplicate comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 197-203: The current save path inside the
Invoices.with_manual_prediction block unconditionally calls
Invoices.set_invoice_category and Invoices.set_invoice_tags so a user with only
one permission can change both; create helper functions
maybe_set_category(invoice, category_id, can_set_category) and
maybe_set_tags(invoice_id, tag_ids, can_set_tags) (as described) and replace the
direct calls in the anonymous function passed to Invoices.with_manual_prediction
(the code that currently does with {:ok, updated} <-
Invoices.set_invoice_category(...), {:ok, _tags} <-
Invoices.set_invoice_tags(...)) to instead call maybe_set_category and then
maybe_set_tags, passing the current can_set_category and can_set_tags booleans
so each setter runs only when that specific permission is true and otherwise
returns {:ok, unchanged}.

In `@lib/ksef_hub_web/live/tag_live/form.ex`:
- Around line 39-59: When handling the :not_found branch in apply_action/3 for
:edit, include the current tag type from socket assigns in the redirect so the
UI stays on the same tab; change the push_navigate call that currently uses
~p"/c/#{company_id}/tags" to include the type query param (e.g. use
socket.assigns.tag_type or a fallback and build the path like
~p"/c/#{company_id}/tags?type=#{tag_type}") so the ?type=... is preserved when
navigating away on error.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/classify.ex`:
- Around line 192-193: The one-line pipeline assigning tag_ids is too long;
split it into multiple steps for readability by first binding the selected set
(e.g., selected_tags = socket.assigns.selected_tag_ids), then computing the
intersection (e.g., intersected = MapSet.intersection(selected_tags,
allowed_tag_ids)), and finally converting to a list (e.g., tag_ids =
MapSet.to_list(intersected)); update references to use these intermediate
variables and keep each function call on its own line.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5eaf3a4f-1761-4441-b2e7-f7307868b645

📥 Commits

Reviewing files that changed from the base of the PR and between d451552 and 1ec0d65.

📒 Files selected for processing (2)
  • lib/ksef_hub_web/live/invoice_live/classify.ex
  • lib/ksef_hub_web/live/tag_live/form.ex

Comment thread lib/ksef_hub_web/live/tag_live/form.ex
emilwojtaszek and others added 19 commits March 18, 2026 12:33
Previously, resolve_predictions only set category/tag when confidence
was above threshold. Below-threshold predictions left category_id as nil
even when a matching company category existed. Now the best prediction
is always applied, with the threshold only determining prediction_status
(:predicted vs :needs_review).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New /invoices/:id/classify page with collapsible category groups
(grouped by prefix before ':'), tag checkboxes, inline tag creation,
and atomic save via with_manual_prediction. The page tracks local
state before persisting, allowing cancel without side effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace inline category dropdown and tag checkboxes in the invoice
detail page with read-only category_badge and tag_list components.
Add Edit button linking to the new /classify page. Remove set_category,
toggle_tag, and create_and_add_tag event handlers and related assigns
(category_form, new_tag_form, tag_form_key, all_tags) from show.ex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Matches the Details and Classification section button style for
consistency across the invoice detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fix conflicting text-sm/text-xs classes on Category and Tags labels.
Use text-sm text-muted-foreground matching the Details table style.
Normalize heading margin to mb-2 across all three sidebar cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comments card was placed outside the grid, causing it to span the full
page width. Move it into the sidebar column (space-y-4 div) alongside
Details, Classification, and Note cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Smaller avatar (w-6 h-6 instead of w-8 h-8)
- Comment body aligned with sender name (nested inside same flex column)
- Reduced vertical spacing between comments (space-y-2 instead of space-y-4)
- Single-row textarea with resize-none for compact input
- Smaller Post button matching input height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use divide-y with py-2 instead of space-y-2 for tighter comment rows
- Comment body left-aligned with sender name via ml-7 (matching avatar+gap)
- Flat layout: name/time/actions on one line, body directly below
- Smaller avatar (w-5 h-5)
- Auto-growing textareas via field-sizing:content + oninput fallback
- No empty space below single-line comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace <p> with <div> for comment body to eliminate browser default
margins. Reduce row padding from py-2 to py-1.5.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Slack-style compact layout: bold name + timestamp on one line, body
text directly below at the same indent level. No avatars, no indentation
offsets. Minimal spacing with space-y-0.5. Body in muted color to
distinguish from the header. Plain icon buttons instead of button
components for actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Rename category `name` to `identifier` (ML model key, group:target format)
- Add `name` (display name), `examples` fields to categories
- Add Claude Haiku-powered emoji auto-generation via Anthropic API
- Split CategoryLive and TagLive into Index + Form pages
- Fix tag unique constraint name to match actual DB index
- Update all references across classifier, UI, API, and exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix current_form_params losing sort_order=0 (falsy integer → empty string)
- Add emoji_generator_req_options to test config (prevent real API calls)
- Remove duplicated editing?/1 helper, inline @live_action == :edit
- Add EmojiGenerator.Client unit tests (API responses, prompt building,
  emoji extraction, error handling)
- Add CategoryLive.Form emoji generation tests (success, failure, crash)
- Add migration docstring explaining schema evolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…verage

- Add server-side permission guards on classify save/create_tag events
- Extract prediction_hint component into InvoiceComponents (was duplicated)
- Fix cond fallback in prediction_hint for nil confidence edge case
- Fix migration index drop to use explicit name after column rename
- Fix emoji generator client test to use async: false (Application env mutation)
- Add permission enforcement tests for classify page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After rebasing onto main (which merged feat/expense-categories-typed-tags),
the refactored split LiveViews were missing typed tag support:

- tag_live/index: add Expense/Income tabs with ?type= query param filtering
- tag_live/form: pass tag type from query param, show type in header
- category_live/index: restore "Expense Categories" title
- classify: filter tags by invoice type, pass type on inline tag creation
- tests: use type: :expense for invoices needing categories, fix async mox

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add missing `type` enum to UpdateTagRequest OpenAPI schema
- Hide category section on classify page for income invoices
- Handle :expense_only error explicitly in save_classification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ixes

- Gate category/tag auto-apply on confidence threshold (was applying
  regardless of confidence when a matching record existed)
- Preserve tag type query param across create/update/cancel redirects
- Whitelist tag IDs against server-side allowed tags in classify page
- Remove nested <.button> inside <.link> for accessibility compliance
- Add @doc strings to CategoryLive.Form public callbacks
- Standardize emoji generator test cleanup with shared setup/on_exit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
emilwojtaszek and others added 3 commits March 18, 2026 12:34
Replace nested if/else with cond to fix Credo nesting depth warning.
Auto-format long lines in classify.ex and tag form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nnotations

- Add maybe_set_category/3 and maybe_set_tags/3 helpers so each setter
  only runs when the user holds that specific permission
- Preserve ?type= query param on tag not-found redirect
- Add @doc to all public callbacks in TagLive.Form
- Split tag_ids pipeline for readability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… Edit button

- Remove "Add payment" button from header (intentionally kept in Payment Requests section only)
- Add @data_editable guard to Details Edit button so KSeF invoices hide it
- Add data-testid to Edit button for precise test targeting
- Update tag redirect test to expect preserved ?type= query param

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek force-pushed the feat/classification-ui-refactor branch from 46a46de to b6ff96a Compare March 18, 2026 11:40
@emilwojtaszek emilwojtaszek merged commit d1497dd into main Mar 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant