Skip to content

Feat/invoice tagging frontend#54

Merged
emilwojtaszek merged 19 commits into
mainfrom
feat/invoice-tagging-frontend
Feb 25, 2026
Merged

Feat/invoice tagging frontend#54
emilwojtaszek merged 19 commits into
mainfrom
feat/invoice-tagging-frontend

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 25, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Category and Tag management pages (full CRUD), new sidebar links, invoice detail classification UI with inline tag creation, list filters/columns for Category and Tag, prediction indicator, and reusable UI components.
  • Bug Fixes

    • Validation error reporting adjusted for duplicate category and tag names.
  • Tests

    • Expanded coverage for category/tag UIs, filters, invoice list/show behavior, usage counts, and role scoping.
  • Chores

    • Added local database service and default category seed data.

emilwojtaszek and others added 13 commits February 25, 2026 07:52
…etails/3

- list_invoices_paginated now preloads [:category, :tags] on entries
- Add non-raising get_invoice_with_details/3 with preloads
- Add tests for preloads and the new function

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add category_badge, tag_list, prediction_indicator components
- Add Category, Tags, and Review indicator columns to index table
- Add tests for new column rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use get_invoice_with_details to preload associations on mount
- Load categories and all_tags into assigns for editing UI
- Add Classification card with category select dropdown and tag checkboxes
- Add set_category event handler (marks prediction as manual)
- Add toggle_tag event handler for adding/removing tags
- Add create_and_add_tag event handler for inline tag creation
- Show prediction indicator in subtitle
- Add tests for display, category editing, tag toggling, inline tag creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Create CategoryLive with stream-based table
- Inline form for create/edit with validation
- Delete with confirmation dialog
- Company-scoped categories
- Add /categories and /tags routes
- Add tests for mount, create, edit, delete, company scoping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Create TagLive with stream-based table showing usage counts
- Inline form for create/edit using .input component for error display
- Delete with confirmation dialog
- Fix unique_constraint error_key on Tag and Category schemas
  to display validation errors on :name field instead of :company_id
- Use .input component in CategoryLive for consistent error display
- Add tests for mount, create, edit, delete, duplicates, company scoping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add nav links visible to all roles (after Invoices)
- Categories uses hero-squares-2x2 icon
- Tags uses hero-tag icon
- Update role-based nav tests to verify new links

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix formatting in tag_live.ex and category_live_test.exs
- Extract do_create_and_add_tag/2 and changeset_message/1 to reduce
  nesting depth (credo --strict compliance)
- Update existing duplicate-name tests to assert error on :name field
  instead of :company_id after error_key change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Load categories and all_tags into assigns on mount
- Add Category and Tag select dropdowns to filter bar
- Parse category_id (UUID) and tag_id URL params into filters
- Preserve category/tag filters across pagination
- Backend already supports :category_id and :tag_ids filters
- Add tests for filtering, URL updates, and dropdown rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the detail link to the Seller name column to save horizontal space.
Update tests to assert on seller_name instead of invoice_number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace .input component with manual form-control divs in both
CategoryLive and TagLive forms. The .input component generates its
own wrapper markup which broke alignment when mixed with manual
inputs in a CSS grid. Use consistent flex-wrap layout instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace DaisyUI .label/.form-control classes with plain block labels.
The .label class applies display:flex which causes labels to sit
beside inputs instead of above them in a flex-wrap context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add db service (postgres:17-alpine) with healthcheck and persistent
volume so developers can run locally without pointing to production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
39 categories across 8 groups (people, office, assets, operations,
recruitment, marketing, sales, others) with emoji, descriptions,
and sort_order preserving the original group ordering.

Idempotent via ON CONFLICT DO NOTHING — safe to re-run on production.

Usage: psql $DATABASE_URL -v company_id="'<UUID>'" -f priv/repo/seed_categories.sql

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

coderabbitai Bot commented Feb 25, 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 company-scoped categories and tags across backend and UI: DB service in docker-compose, context preloads and new getters, schema error_key tweaks, UI components, LiveViews/routes for category/tag CRUD and invoice classification (filters, assignment, inline tag creation), seeds, and tests.

Changes

Cohort / File(s) Summary
Infrastructure
docker-compose.yml
Added db service (postgres:17-alpine), pgdata volume, env vars, port mapping 5432, and a pg_isready healthcheck.
Invoices context & schemas
lib/ksef_hub/invoices.ex, lib/ksef_hub/invoices/category.ex, lib/ksef_hub/invoices/tag.ex
Preload :category and :tags in listing; added get_invoice_with_details/3, tag usage query get_tag_with_usage_count/2, create_and_add_tag/3; unique_constraint calls updated to error_key: :name.
UI components
lib/ksef_hub_web/components/invoice_components.ex
Added category_badge/1, tag_list/1, and prediction_indicator/1 HEEx components with attrs/specs and rendering.
Layout / Routing
lib/ksef_hub_web/components/layouts.ex, lib/ksef_hub_web/router.ex
Sidebar links for Categories and Tags added; authenticated LiveView routes /categories and /tags registered.
LiveViews (new)
lib/ksef_hub_web/live/category_live.ex, lib/ksef_hub_web/live/tag_live.ex
New company-scoped CRUD LiveViews for categories and tags with validation, streaming, and tag usage counting.
Invoice LiveViews (changes)
lib/ksef_hub_web/live/invoice_live/index.ex, lib/ksef_hub_web/live/invoice_live/show.ex
Index: category/tag filters, parsing helpers, UI controls, and table columns. Show: loads invoice with details, handlers for set_category, toggle_tag, create_and_add_tag, and classification UI including prediction indicator.
Seeds
priv/repo/seed_categories.sql
New idempotent SQL seed inserting default categories per company using ON CONFLICT on (company_id, name).
Tests
test/ksef_hub/*, test/ksef_hub_web/live/*
New LiveView tests for categories/tags, added tests for preloading and get_invoice_with_details, updated invoice index/show tests for category/tag rendering & filtering, adjusted unique-constraint assertions to check :name, centralized PDF stubbing.

Sequence Diagram

sequenceDiagram
    participant User as User (Browser)
    participant LV as Invoice LiveView
    participant Ctx as Invoices Context
    participant DB as Database

    User->>LV: open invoice show
    LV->>Ctx: get_invoice_with_details(company_id, invoice_id, role)
    Ctx->>DB: SELECT invoice JOIN categories,tags
    DB-->>Ctx: invoice + category + tags
    Ctx-->>LV: detailed invoice
    LV-->>User: render invoice with category selector and tag list

    User->>LV: set_category(category_id)
    LV->>Ctx: set_category(invoice_id, category_id, user)
    Ctx->>DB: UPDATE invoice (category_id,prediction_status)
    DB-->>Ctx: ok
    Ctx->>DB: SELECT updated invoice + preloads
    DB-->>Ctx: updated invoice
    Ctx-->>LV: updated invoice
    LV-->>User: re-render with new category

    User->>LV: toggle_tag(tag_id)
    LV->>Ctx: toggle_tag(invoice_id, tag_id, user)
    Ctx->>DB: INSERT/DELETE invoice_tag
    DB-->>Ctx: ok
    Ctx->>DB: SELECT updated invoice + preloads
    DB-->>Ctx: updated invoice
    Ctx-->>LV: updated invoice
    LV-->>User: re-render with updated tags
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through code and planted tiny tags,
Badges, seeds, and tidy category bags.
I toggled tags and nudged a prediction,
The dashboard bloomed — a carrot's benediction.
Hop on, the invoices look spry and glad.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.41% 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/invoice tagging frontend' accurately describes the main change: adding frontend UI for invoice tagging, including category and tag management components and LiveView pages.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/invoice-tagging-frontend

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: 5

🧹 Nitpick comments (10)
lib/ksef_hub_web/live/category_live.ex (1)

153-260: Consider extracting form/table into function components.

The render block is doing two large responsibilities (editor + listing). Splitting these into components will simplify future changes and test updates.

Based on learnings, "Extract reusable logic into function components".

🤖 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.ex` around lines 153 - 260, Render is
doing two responsibilities (editor + listing); extract the form and table into
function components to simplify the LiveView. Create two components (e.g.,
category_form(assigns) and category_table(assigns)) that move the form block
(inputs using `@form`, `@editing`, translate_error, phx-submit="save",
phx-change="validate", phx-click handlers, cancel_edit) and the .table block
(rows=@streams.categories, row_id/row_item lambdas, data-testid on category
name, edit/delete phx handlers and confirm) respectively, keep all original
attribute names/IDs/handlers and validation display, then replace the inline
markup in the render with calls to <.category_form ... /> and <.category_table
... /> passing the needed assigns (form, editing, streams, etc.); ensure the
components live in the same module or are imported so existing calls to
edit/delete/save/validate still work.
docker-compose.yml (1)

4-9: Avoid committed default DB credentials and broad host exposure.

This works, but keeping static credentials in source and binding Postgres to all host interfaces is a avoidable risk even for dev environments.

🔧 Suggested hardening
   db:
     image: postgres:17-alpine
     ports:
-      - "5432:5432"
+      - "127.0.0.1:5432:5432"
     environment:
-      POSTGRES_USER: postgres
-      POSTGRES_PASSWORD: postgres
-      POSTGRES_DB: ksef_hub_dev
+      POSTGRES_USER: ${POSTGRES_USER:-postgres}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+      POSTGRES_DB: ${POSTGRES_DB:-ksef_hub_dev}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 4 - 9, The docker-compose service exposes
Postgres to all interfaces and hardcodes credentials; change the ports mapping
from "5432:5432" to bind localhost (e.g., "127.0.0.1:5432:5432") or remove the
host mapping to avoid broad host exposure, and remove hardcoded POSTGRES_USER
and POSTGRES_PASSWORD values from the environment block by loading them from an
external .env or Docker secret (keep POSTGRES_DB configurable via env but not
committed with real credentials); update references to the POSTGRES_USER,
POSTGRES_PASSWORD, and ports mapping in the compose service so secrets/.env are
used instead of literal values.
lib/ksef_hub/invoices.ex (1)

120-128: Consider extracting a shared invoice-details query helper.

get_invoice_with_details!/3 and get_invoice_with_details/3 now duplicate the same query pipeline; a private builder would keep this easier to maintain.

♻️ Optional refactor sketch
+  `@spec` invoice_with_details_query(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: Ecto.Query.t()
+  defp invoice_with_details_query(company_id, id, opts) do
+    Invoice
+    |> where([i], i.company_id == ^company_id and i.id == ^id)
+    |> maybe_scope_type_by_role(opts[:role])
+    |> preload([:category, :tags])
+  end
+
   `@doc` "Fetches an invoice by UUID with category and tags preloaded."
   `@spec` get_invoice_with_details!(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: Invoice.t()
   def get_invoice_with_details!(company_id, id, opts \\ []) do
-    Invoice
-    |> where([i], i.company_id == ^company_id and i.id == ^id)
-    |> maybe_scope_type_by_role(opts[:role])
-    |> preload([:category, :tags])
-    |> Repo.one!()
+    company_id
+    |> invoice_with_details_query(id, opts)
+    |> Repo.one!()
   end

   `@doc` "Fetches an invoice by UUID with category and tags preloaded, returning nil if not found."
   `@spec` get_invoice_with_details(Ecto.UUID.t(), Ecto.UUID.t(), keyword()) :: Invoice.t() | nil
   def get_invoice_with_details(company_id, id, opts \\ []) do
-    Invoice
-    |> where([i], i.company_id == ^company_id and i.id == ^id)
-    |> maybe_scope_type_by_role(opts[:role])
-    |> preload([:category, :tags])
-    |> Repo.one()
+    company_id
+    |> invoice_with_details_query(id, opts)
+    |> Repo.one()
   end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 120 - 128, Extract the shared query
pipeline used by get_invoice_with_details/3 and get_invoice_with_details!/3 into
a private helper (e.g., build_invoice_with_details_query/3) that accepts
company_id, id, and role; move the Invoice |> where(...) |>
maybe_scope_type_by_role(opts[:role]) |> preload([:category, :tags]) logic into
that helper and have get_invoice_with_details/3 call
Repo.one(build_invoice_with_details_query(...)) and get_invoice_with_details!/3
call Repo.one!(build_invoice_with_details_query(...)) so the query construction
is centralized and both functions only differ by the Repo retrieval function.
test/ksef_hub_web/live/invoice_live/show_test.exs (1)

146-147: Prefer selector-based assertions over raw HTML string checks in these new LiveView tests.

The new assertions are readable, but they’re brittle against markup changes; has_element?/3 with stable selectors would make these tests more resilient.

🧪 Example direction
-      html = render(view)
-      assert html =~ "finance:invoices"
+      assert has_element?(view, "[data-testid=category-select]")
+      assert has_element?(view, "[data-testid=category-name]", "finance:invoices")
-      {:ok, _view, html} = live(conn, ~p"/invoices/#{invoice.id}")
-      assert html =~ "quarterly-report"
+      {:ok, view, _html} = live(conn, ~p"/invoices/#{invoice.id}")
+      assert has_element?(view, "[data-testid=tag-badge]", "quarterly-report")

Also applies to: 155-157, 162-164, 180-182, 244-246

🤖 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 146 - 147,
Replace brittle raw HTML string assertions that use render(view) and assert html
=~ "..." with LiveView helper has_element?/3 calls to target stable selectors;
e.g., instead of capturing html = render(view) and asserting the substring, call
assert has_element?(view, "CSS_OR_DATA_TEST_SELECTOR", "finance:invoices") (and
similarly for the other occurrences). Update the tests in
invoice_live/show_test.exs to use meaningful selectors (prefer data-test
attributes or component classes) and assert via has_element?(view, selector,
expected_text) for each of the spots currently using assert html =~.
lib/ksef_hub_web/live/invoice_live/index.ex (2)

13-21: Categories and tags loaded once in mount will become stale during the LiveView session.

If a user (or another tab) adds/deletes a category or tag, the filter dropdowns won't reflect that until the page is fully remounted. Consider reloading categories and all_tags in handle_params alongside invoices, so a push_patch picks up changes.

This is non-critical since a page reload fixes it, but it's worth noting for UX consistency—especially since the tag/category management pages are separate LiveViews that could be used in a parallel tab.

🤖 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/index.ex` around lines 13 - 21, Currently
categories and all_tags are only loaded in mount (assign in mount), which
becomes stale; update the LiveView to reload categories and all_tags inside
handle_params (the same place invoices are refreshed) by calling
Invoices.list_categories(current_company.id) and
Invoices.list_tags(current_company.id) and assigning the results to :categories
and :all_tags on the socket so that push_patch navigation picks up changes; keep
mount minimal (e.g., noop or initial assigns) but ensure handle_params uses
socket.assigns.current_company (or company_id) to refetch and assign
categories/all_tags whenever params change.

50-53: Operator precedence on tag_id extraction is correct but somewhat opaque.

The expression filters[:tag_ids] |> List.wrap() |> List.first() || "" works because || binds more loosely than |>, but it can surprise readers. A minor clarity improvement:

♻️ Optional: explicit parentheses for readability
-        "tag_id" => filters[:tag_ids] |> List.wrap() |> List.first() || ""
+        "tag_id" => (filters[:tag_ids] |> List.wrap() |> List.first()) || ""
🤖 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/index.ex` around lines 50 - 53, The
current "tag_id" value expression in the map building uses a pipeline whose
result is then fallback-ed with || which relies on operator precedence; update
the expression to make precedence explicit by wrapping the pipeline in
parentheses so the fallback applies to the pipeline result (i.e., change the
"tag_id" entry where filters[:tag_ids] |> List.wrap() |> List.first() || "" is
used). This clarifies intent for future readers; alternatively you may assign
the pipeline result to a temp variable (e.g., tag_id) and then use tag_id || ""
before returning the map.
lib/ksef_hub_web/live/invoice_live/show.ex (3)

214-217: tag_assigned?/2 is a duplicate of the inline check at line 131.

Both line 131 and line 215 do Enum.any?(invoice.tags, &(&1.id == tag_id)). The private function is only used in the template. Consider also using it at line 131 for consistency.

♻️ Optional: reuse tag_assigned? in toggle_tag
   def handle_event("toggle_tag", %{"tag-id" => tag_id}, socket) do
     invoice = socket.assigns.invoice
-    currently_assigned = Enum.any?(invoice.tags, &(&1.id == tag_id))
+    currently_assigned = tag_assigned?(invoice, tag_id)
🤖 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 214 - 217, Replace
the duplicated inline check Enum.any?(invoice.tags, &(&1.id == tag_id)) with the
existing private helper tag_assigned?(invoice, tag_id) to avoid duplication;
locate the inline check in the toggle_tag logic (the earlier usage around line
131) and call tag_assigned?/2 there instead of repeating the Enum.any?
expression so the template and toggle_tag both use the same helper.

160-182: create_tag + add_invoice_tag is not wrapped in a transaction.

If create_tag succeeds but add_invoice_tag fails, an orphaned tag will exist (created but not associated with the invoice). While this is a low-probability scenario (the tag is in the same company), it could leave stale data.

Consider wrapping in Ecto.Multi or Repo.transaction/1 for atomicity. This is non-critical since the orphaned tag is still a valid tag in the company and would appear in the tags list.

🤖 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 160 - 182,
do_create_and_add_tag currently calls Invoices.create_tag and
Invoices.add_invoice_tag separately, which can leave an orphaned tag if the
second call fails; change this to perform both steps in a single atomic
operation (use Ecto.Multi or Repo.transaction) so that tag creation is rolled
back when add_invoice_tag fails. Implement the transaction inside
do_create_and_add_tag (or add a helper in Invoices) to run Invoices.create_tag
and Invoices.add_invoice_tag as one multi/transaction, preserve the existing
branch behavior on {:ok, ...} and map transactional errors back to the same
changeset or generic error flash, and refresh invoice/all_tags via
reload_details and Invoices.list_tags on success.

370-382: phx-keyup="new_tag_input" on every keystroke without debounce.

Every keystroke triggers a round-trip to the server. Consider adding phx-debounce="300" to reduce unnecessary traffic, consistent with the search input in index.ex (line 296).

♻️ Optional: add debounce
                  name="name"
                  value={`@new_tag_name`}
                  phx-keyup="new_tag_input"
+                 phx-debounce="300"
                  placeholder="New tag..."
🤖 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 370 - 382, The input
field handling tag creation currently fires phx-keyup="new_tag_input" on every
keystroke causing excessive server round-trips; update the tag input element
(the input with name="name" and phx-keyup="new_tag_input" used in the
create_and_add_tag form) to include phx-debounce="300" so keyup events are
debounced (match the debounce used in the search input), keeping server updates
less frequent while preserving the current create_and_add_tag submit behavior.
lib/ksef_hub_web/live/tag_live.ex (1)

30-33: validate event doesn't run changeset validation — intentional?

This mirrors the pattern in CategoryLive, but the form won't show real-time validation errors (e.g., name uniqueness, required field) until submission. If that's acceptable UX, this is fine.

🤖 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.ex` around lines 30 - 33, The "validate"
handle_event currently just wraps raw params with to_form so no changeset
validations run; update handle_event("validate", %{"tag" => params}, socket) to
build and validate a changeset (e.g., call Tag.changeset/2 with the params), set
the changeset action to :validate, and assign the form from that validated
changeset (use to_form(changeset)) so real-time validation errors
(required/uniqueness) are shown; reference handle_event/3, the "validate" event,
Tag.changeset/2, and to_form.
🤖 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.ex`:
- Around line 12-25: Add missing `@doc` and `@spec` annotations for each public
callback in this module (e.g., mount/3 and the other public functions in ranges
29-83 and 145-262). For mount/3 add a brief `@doc` describing that it initializes
assigns and streams categories, and a `@spec` like mount(map(), map(),
Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}; apply the
same pattern to other public callbacks: add a one-line `@doc` describing purpose
and a `@spec` with concrete argument and return types (use
Phoenix.LiveView.Socket.t(), list/map types, and {:noreply, Socket.t()} or {:ok,
Socket.t()} as appropriate) so every public function has both annotations.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 184-189: The current changeset_message/1 discards translation
parameters by calling Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end);
update changeset_message to call traverse_errors with a translator that applies
the error opts (e.g., &translate_error/1 or a function that calls
Gettext.dngettext/translate with the {msg, opts} tuple) so parameterized
messages like "should be at most %{count} characters" are interpolated
correctly; keep the rest of the function (Enum.map_join) intact so keys and
joined messages are returned as before.

In `@lib/ksef_hub_web/live/tag_live.ex`:
- Around line 84-101: Created/updated tag structs lack the virtual usage_count
(computed in list_tags), so update create_tag and update_tag to reload the saved
tag with usage_count before streaming: after Invoices.create_tag /
Invoices.update_tag returns {:ok, tag}, call a function that returns the tag
including the select_merge usage_count (e.g.
Invoices.get_tag_with_usage_count(tag.id) or fetch from
Invoices.list_tags(socket.assigns.current_company.id) and find by id) and then
pass that reloaded tag into stream_insert(:tags, ...) or stream_replace(:tags,
...); keep the existing assigns (form: new_form() or to_form(...)) and flash
behavior. Ensure you reference the reloaded tag (with usage_count) instead of
the raw Repo result when calling stream_insert/stream_replace.

In `@test/ksef_hub_web/live/category_live_test.exs`:
- Around line 25-29: Replace raw HTML string assertions in the "renders
categories page" test (and the other listed tests) with selector-driven LiveView
assertions: use element/2 and has_element?/2 (or render_change/render_click on
element/2) to assert presence of "#categories" container, "#category-form" form,
and specific rows/fields via their data-testid attributes (e.g.,
data-testid="category-row" or data-testid="category-name") in the test "renders
categories page" and corresponding tests instead of matching html =~
"Categories" or "New Category"; update the assertions to target those stable
selectors so they assert DOM presence and structure rather than raw text.

In `@test/ksef_hub_web/live/tag_live_test.exs`:
- Around line 26-29: The tests in tag_live_test.exs are asserting raw HTML
fragments from the live(conn, ~p"/tags") response (e.g., checking "Tags", "New
Tag", and usage counts) which is fragile; update each assertion to use LiveView
test selector helpers (element/2, has_element?/2, render_click/render_submit
where needed) targeting stable IDs or data attributes you added in the templates
(for example replace assert html =~ "Tags" with assert has_element?(view,
"#tags-heading") and assert html =~ "New Tag" with assert has_element?(view,
"#new-tag-button"); for usage counts replace text matching with
has_element?(view, "#tag-#{tag.id}-usage", "> 0") or by querying element(view,
"#tag-#{tag.id}-usage") and asserting its rendered text). Ensure you update
assertions around live/2, render_click, and render_submit calls (and all other
similar assertions noted in the comment) to use element/2 and has_element?/2
with the element IDs or data-testid attributes from your LiveView templates.

---

Nitpick comments:
In `@docker-compose.yml`:
- Around line 4-9: The docker-compose service exposes Postgres to all interfaces
and hardcodes credentials; change the ports mapping from "5432:5432" to bind
localhost (e.g., "127.0.0.1:5432:5432") or remove the host mapping to avoid
broad host exposure, and remove hardcoded POSTGRES_USER and POSTGRES_PASSWORD
values from the environment block by loading them from an external .env or
Docker secret (keep POSTGRES_DB configurable via env but not committed with real
credentials); update references to the POSTGRES_USER, POSTGRES_PASSWORD, and
ports mapping in the compose service so secrets/.env are used instead of literal
values.

In `@lib/ksef_hub_web/live/category_live.ex`:
- Around line 153-260: Render is doing two responsibilities (editor + listing);
extract the form and table into function components to simplify the LiveView.
Create two components (e.g., category_form(assigns) and category_table(assigns))
that move the form block (inputs using `@form`, `@editing`, translate_error,
phx-submit="save", phx-change="validate", phx-click handlers, cancel_edit) and
the .table block (rows=@streams.categories, row_id/row_item lambdas, data-testid
on category name, edit/delete phx handlers and confirm) respectively, keep all
original attribute names/IDs/handlers and validation display, then replace the
inline markup in the render with calls to <.category_form ... /> and
<.category_table ... /> passing the needed assigns (form, editing, streams,
etc.); ensure the components live in the same module or are imported so existing
calls to edit/delete/save/validate still work.

In `@lib/ksef_hub_web/live/invoice_live/index.ex`:
- Around line 13-21: Currently categories and all_tags are only loaded in mount
(assign in mount), which becomes stale; update the LiveView to reload categories
and all_tags inside handle_params (the same place invoices are refreshed) by
calling Invoices.list_categories(current_company.id) and
Invoices.list_tags(current_company.id) and assigning the results to :categories
and :all_tags on the socket so that push_patch navigation picks up changes; keep
mount minimal (e.g., noop or initial assigns) but ensure handle_params uses
socket.assigns.current_company (or company_id) to refetch and assign
categories/all_tags whenever params change.
- Around line 50-53: The current "tag_id" value expression in the map building
uses a pipeline whose result is then fallback-ed with || which relies on
operator precedence; update the expression to make precedence explicit by
wrapping the pipeline in parentheses so the fallback applies to the pipeline
result (i.e., change the "tag_id" entry where filters[:tag_ids] |> List.wrap()
|> List.first() || "" is used). This clarifies intent for future readers;
alternatively you may assign the pipeline result to a temp variable (e.g.,
tag_id) and then use tag_id || "" before returning the map.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 214-217: Replace the duplicated inline check
Enum.any?(invoice.tags, &(&1.id == tag_id)) with the existing private helper
tag_assigned?(invoice, tag_id) to avoid duplication; locate the inline check in
the toggle_tag logic (the earlier usage around line 131) and call
tag_assigned?/2 there instead of repeating the Enum.any? expression so the
template and toggle_tag both use the same helper.
- Around line 160-182: do_create_and_add_tag currently calls Invoices.create_tag
and Invoices.add_invoice_tag separately, which can leave an orphaned tag if the
second call fails; change this to perform both steps in a single atomic
operation (use Ecto.Multi or Repo.transaction) so that tag creation is rolled
back when add_invoice_tag fails. Implement the transaction inside
do_create_and_add_tag (or add a helper in Invoices) to run Invoices.create_tag
and Invoices.add_invoice_tag as one multi/transaction, preserve the existing
branch behavior on {:ok, ...} and map transactional errors back to the same
changeset or generic error flash, and refresh invoice/all_tags via
reload_details and Invoices.list_tags on success.
- Around line 370-382: The input field handling tag creation currently fires
phx-keyup="new_tag_input" on every keystroke causing excessive server
round-trips; update the tag input element (the input with name="name" and
phx-keyup="new_tag_input" used in the create_and_add_tag form) to include
phx-debounce="300" so keyup events are debounced (match the debounce used in the
search input), keeping server updates less frequent while preserving the current
create_and_add_tag submit behavior.

In `@lib/ksef_hub_web/live/tag_live.ex`:
- Around line 30-33: The "validate" handle_event currently just wraps raw params
with to_form so no changeset validations run; update handle_event("validate",
%{"tag" => params}, socket) to build and validate a changeset (e.g., call
Tag.changeset/2 with the params), set the changeset action to :validate, and
assign the form from that validated changeset (use to_form(changeset)) so
real-time validation errors (required/uniqueness) are shown; reference
handle_event/3, the "validate" event, Tag.changeset/2, and to_form.

In `@lib/ksef_hub/invoices.ex`:
- Around line 120-128: Extract the shared query pipeline used by
get_invoice_with_details/3 and get_invoice_with_details!/3 into a private helper
(e.g., build_invoice_with_details_query/3) that accepts company_id, id, and
role; move the Invoice |> where(...) |> maybe_scope_type_by_role(opts[:role]) |>
preload([:category, :tags]) logic into that helper and have
get_invoice_with_details/3 call Repo.one(build_invoice_with_details_query(...))
and get_invoice_with_details!/3 call
Repo.one!(build_invoice_with_details_query(...)) so the query construction is
centralized and both functions only differ by the Repo retrieval function.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs`:
- Around line 146-147: Replace brittle raw HTML string assertions that use
render(view) and assert html =~ "..." with LiveView helper has_element?/3 calls
to target stable selectors; e.g., instead of capturing html = render(view) and
asserting the substring, call assert has_element?(view,
"CSS_OR_DATA_TEST_SELECTOR", "finance:invoices") (and similarly for the other
occurrences). Update the tests in invoice_live/show_test.exs to use meaningful
selectors (prefer data-test attributes or component classes) and assert via
has_element?(view, selector, expected_text) for each of the spots currently
using assert html =~.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 54eeb8d and 93bf2d8.

📒 Files selected for processing (20)
  • docker-compose.yml
  • 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/components/layouts.ex
  • lib/ksef_hub_web/live/category_live.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/router.ex
  • priv/repo/seed_categories.sql
  • test/ksef_hub/invoices/categories_test.exs
  • test/ksef_hub/invoices/tags_test.exs
  • test/ksef_hub/invoices_test.exs
  • test/ksef_hub_web/live/category_live_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/role_based_nav_test.exs
  • test/ksef_hub_web/live/tag_live_test.exs

Comment thread lib/ksef_hub_web/live/category_live.ex
Comment thread lib/ksef_hub_web/live/invoice_live/show.ex
Comment on lines +84 to +101
defp create_tag(socket, params) do
company_id = socket.assigns.current_company.id

case Invoices.create_tag(company_id, %{
name: params["name"],
description: params["description"]
}) do
{:ok, tag} ->
{:noreply,
socket
|> stream_insert(:tags, tag)
|> assign(form: new_form())
|> put_flash(:info, "Tag created.")}

{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset, as: :tag))}
end
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find the Tag schema and check how usage_count is defined
fd -t f -e ex -e exs | xargs grep -l "schema.*tag" -i

Repository: appunite/appunite-ksef-ex

Length of output: 538


🏁 Script executed:

# Search for the Tag schema definition
rg -n "^defmodule.*Tag" --type=elixir

Repository: appunite/appunite-ksef-ex

Length of output: 1292


🏁 Script executed:

# Look for usage_count field definition
rg -n "usage_count" --type=elixir -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 4661


🏁 Script executed:

# Check the list_tags implementation
rg -n "def list_tags" --type=elixir -A 20

Repository: appunite/appunite-ksef-ex

Length of output: 1313


🏁 Script executed:

# Check create_tag and update_tag implementations
rg -n "def create_tag|def update_tag" --type=elixir -A 15

Repository: appunite/appunite-ksef-ex

Length of output: 1574


🏁 Script executed:

# Verify the template at line 203 of tag_live.ex
sed -n '200,210p' lib/ksef_hub_web/live/tag_live.ex

Repository: appunite/appunite-ksef-ex

Length of output: 503


Stream newly created and updated tags with usage_count populated.

When create_tag and update_tag return tags from Repo.insert() and Repo.update(), they lack the usage_count virtual field that is computed via select_merge in list_tags. The template at line 203 renders {tag.usage_count}, which displays nil (blank) instead of 0 for newly inserted or updated stream entries.

🔧 Proposed fix

For create_tag:

       {:ok, tag} ->
         {:noreply,
          socket
-         |> stream_insert(:tags, tag)
+         |> stream_insert(:tags, %{tag | usage_count: 0})
          |> assign(form: new_form())
          |> put_flash(:info, "Tag created.")}

For update_tag, preserve the existing count:

       {:ok, updated} ->
+        updated = %{updated | usage_count: tag.usage_count}
         {:noreply,
          socket
          |> stream_insert(:tags, updated)
🤖 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.ex` around lines 84 - 101, Created/updated tag
structs lack the virtual usage_count (computed in list_tags), so update
create_tag and update_tag to reload the saved tag with usage_count before
streaming: after Invoices.create_tag / Invoices.update_tag returns {:ok, tag},
call a function that returns the tag including the select_merge usage_count
(e.g. Invoices.get_tag_with_usage_count(tag.id) or fetch from
Invoices.list_tags(socket.assigns.current_company.id) and find by id) and then
pass that reloaded tag into stream_insert(:tags, ...) or stream_replace(:tags,
...); keep the existing assigns (form: new_form() or to_form(...)) and flash
behavior. Ensure you reference the reloaded tag (with usage_count) instead of
the raw Repo result when calling stream_insert/stream_replace.

Comment on lines +25 to +29
test "renders categories page", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/categories")
assert html =~ "Categories"
assert html =~ "New Category"
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 | 🟡 Minor

Use selector-driven assertions instead of raw HTML matching in this LiveView suite.

These checks should target stable selectors (#category-form, #categories, data-testid rows/fields) to avoid brittle failures from markup/text shifts.

🔧 Example refactor pattern
-      {:ok, _view, html} = live(conn, ~p"/categories")
-      assert html =~ "finance:invoices"
+      {:ok, view, _html} = live(conn, ~p"/categories")
+      assert has_element?(view, ~s([data-testid="category-name-#{cat.id}"]), "finance:invoices")
-      html = render(view)
-      assert html =~ "Category deleted."
-      refute html =~ "delete:me"
+      assert has_element?(view, "#categories")
+      refute has_element?(view, ~s([data-testid="category-name-#{cat.id}"]))
As per coding guidelines: "Never test against raw HTML - always use `element/2`, `has_element/2`, and similar functions with selectors..." and "Always reference the key element IDs you added in the LiveView templates...".

Also applies to: 34-37, 55-57, 69-71, 82-85, 100-103, 111-115, 123-130, 138-140

🤖 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 25 - 29, Replace
raw HTML string assertions in the "renders categories page" test (and the other
listed tests) with selector-driven LiveView assertions: use element/2 and
has_element?/2 (or render_change/render_click on element/2) to assert presence
of "#categories" container, "#category-form" form, and specific rows/fields via
their data-testid attributes (e.g., data-testid="category-row" or
data-testid="category-name") in the test "renders categories page" and
corresponding tests instead of matching html =~ "Categories" or "New Category";
update the assertions to target those stable selectors so they assert DOM
presence and structure rather than raw text.

Comment on lines +26 to +29
{:ok, _view, html} = live(conn, ~p"/tags")
assert html =~ "Tags"
assert html =~ "New Tag"
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 | 🟡 Minor

Replace raw HTML and markup-fragment assertions with stable selector assertions.

This suite currently depends on text and exact HTML fragments (notably the usage count assertion), which is fragile for LiveView rendering changes.

🔧 Example refactor pattern
-      {:ok, _view, html} = live(conn, ~p"/tags")
-      assert html =~ "counted"
-      assert html =~ ">1</span>"
+      {:ok, view, _html} = live(conn, ~p"/tags")
+      assert has_element?(view, ~s([data-testid="tag-name-#{tag.id}"]), "counted")
+      assert has_element?(view, "#tags", "1")
-      html = render(view)
-      assert html =~ "Tag deleted."
-      refute html =~ "delete-me"
+      assert has_element?(view, "#tags")
+      refute has_element?(view, ~s([data-testid="tag-name-#{tag.id}"]))
As per coding guidelines: "Never test against raw HTML - always use `element/2`, `has_element/2`, and similar functions with selectors..." and "Always reference the key element IDs you added in the LiveView templates...".

Also applies to: 34-36, 43-47, 58-60, 72-74, 85-88, 101-104, 112-116, 124-131, 139-140

🤖 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 26 - 29, The tests in
tag_live_test.exs are asserting raw HTML fragments from the live(conn,
~p"/tags") response (e.g., checking "Tags", "New Tag", and usage counts) which
is fragile; update each assertion to use LiveView test selector helpers
(element/2, has_element?/2, render_click/render_submit where needed) targeting
stable IDs or data attributes you added in the templates (for example replace
assert html =~ "Tags" with assert has_element?(view, "#tags-heading") and assert
html =~ "New Tag" with assert has_element?(view, "#new-tag-button"); for usage
counts replace text matching with has_element?(view, "#tag-#{tag.id}-usage", ">
0") or by querying element(view, "#tag-#{tag.id}-usage") and asserting its
rendered text). Ensure you update assertions around live/2, render_click, and
render_submit calls (and all other similar assertions noted in the comment) to
use element/2 and has_element?/2 with the element IDs or data-testid attributes
from your LiveView templates.

emilwojtaszek and others added 3 commits February 25, 2026 17:11
- Add @doc/@SPEC to all public callbacks in CategoryLive and TagLive
- Fix changeset_message/1 to use translate_error/1 instead of
  discarding interpolation opts (e.g. %{count} in error messages)
- Fix TagLive usage_count: set virtual field after create (0) and
  preserve existing count after update, so table doesn't show nil
- Use tag_assigned?/2 helper in toggle_tag instead of inline Enum.any?
- Add phx-debounce="300" to new tag input to reduce server round-trips
- Extract first_tag_id variable in filter_assigns for clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CategoryLive and TagLive:
- Assign company_id once in mount, reference from assigns everywhere
- Use real changeset validation in "validate" events so errors show
  on keystrokes (not just on submit)
- Use changeset-backed forms throughout (new_changeset_form replaces
  raw param maps)
- Edit handler builds form from changeset instead of manual map

show.ex:
- Consolidate two set_category handlers (empty vs non-empty) into one
  by normalizing "" to nil upfront

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The category <select> had phx-change directly on it without being inside a
<form>, which can cause the change event to not fire properly in the browser
with LiveView 1.x. This led to category selections reverting after a few
seconds (socket reconnect with unsaved state).

Also separated mark_prediction_manual from the category save flow so a
prediction status failure cannot silently prevent the category update from
being reflected in the UI.

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

@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

Caution

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

⚠️ Outside diff range comments (1)
lib/ksef_hub_web/live/invoice_live/show.ex (1)

17-36: ⚠️ Potential issue | 🟡 Minor

socket.assigns.current_company could raise KeyError if the key is absent.

Line 18 uses dot-access on socket.assigns, which is a plain map. If current_company is never set (e.g., during a socket reconnect or a plug bypass), this raises a KeyError before the cond guard on line 27 gets a chance to check for nil.

🛡️ Safer access
-    company = socket.assigns.current_company
+    company = socket.assigns[:current_company]
🤖 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 17 - 36, In mount/3
replace the unsafe dot-access of socket.assigns.current_company with a safe
fetch (e.g., company = Map.get(socket.assigns, :current_company) or company =
socket.assigns[:current_company]) so accessing the missing key does not raise;
then continue to use that local company variable in the existing cond branch
that calls mount_invoice/3 and in the nil check branch so behavior remains
identical but safe on reconnects or missing assigns.
♻️ Duplicate comments (1)
lib/ksef_hub_web/live/invoice_live/show.ex (1)

177-182: Past issue resolved — changeset_message/1 now uses &translate_error/1.

This addresses the earlier review comment about discarded error opts. The parameterized messages (e.g., %{count}) will now be interpolated correctly.

🤖 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 177 - 182, The
changeset_message/1 now correctly pipes errors through &translate_error/1, but
ensure translate_error/1 is actually available and used: import or alias the
module that defines translate_error/1 (commonly ErrorHelpers or your
Web.ErrorHelpers) so the function resolves at compile time; also normalize the
field key in the Enum.map_join by converting k to a readable string (handle
atoms and binaries) before joining the error texts so the output shows a
humanized field name plus the interpolated messages (e.g., transform k via
Atom.to_string/1 or leave as-is if already a string, replace underscores and
capitalize).
🧹 Nitpick comments (5)
lib/ksef_hub_web/live/invoice_live/show.ex (2)

364-376: Minor UX: phx-keyup + server-assigned value= may cause input flickering.

The phx-keyup="new_tag_input" updates @new_tag_name on the server, which re-renders the input with value={@new_tag_name}. Combined with phx-debounce="300", this can cause the cursor to jump or characters to appear out of order under latency. Since this is a small "tag name" field (short strings, low frequency), the impact is minimal — but if you notice UX issues in testing, consider dropping the phx-keyup binding entirely and relying solely on the form submit params.

🤖 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 364 - 376, The input
uses phx-keyup="new_tag_input" which updates `@new_tag_name` on the server and
re-renders the input value={`@new_tag_name`}, causing cursor flicker under
latency; remove the real-time binding by deleting phx-keyup="new_tag_input" (and
any server handler new_tag_input if now unused) so the field is only submitted
via the form action "create_and_add_tag" and read from params, leaving
phx-debounce out or only on client-side handlers if you later reintroduce live
updates.

66-99: Inconsistent @doc on @impl callbacks.

Line 66 has a @doc for the handle_event("approve"/…) callbacks, but the new handle_event callbacks at lines 103, 121, 140, and 145 lack @doc annotations. Per coding guidelines, every public function must have a @doc. Even for @impl callbacks, keeping them consistent is good practice.

As per coding guidelines: "Every public function must have a @doc annotation".

Also applies to: 103-151

🤖 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 66 - 99, Add missing
`@doc` annotations for all public handle_event/3 clauses to match the existing
pattern: each handle_event("approve", _params, socket) and
handle_event("reject", _params, socket) already have docs, so add brief `@doc`
strings above the other handle_event/3 implementations (the remaining
handle_event clauses in this module) describing the event handled and its
behavior (e.g., "Handles X action for invoices" or similar). Ensure every public
function (all handle_event/3 clauses) has a `@doc` line just above the `@impl` true
and before the def so the module follows the "every public function must have a
`@doc`" guideline.
lib/ksef_hub_web/live/invoice_live/index.ex (2)

42-55: Duplicated tag-id extraction — consider a small helper.

The pattern filters[:tag_ids] |> List.wrap() |> List.first() appears identically here (line 44) and in pagination_params (line 176). A tiny private helper (e.g., first_tag_id/1) would keep both call sites in sync.

♻️ Proposed helper
+  defp first_tag_id(filters), do: filters[:tag_ids] |> List.wrap() |> List.first()

Then replace both occurrences with first_tag_id(filters).

🤖 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/index.ex` around lines 42 - 55, The
duplicated extraction of the first tag id (filters[:tag_ids] |> List.wrap() |>
List.first()) should be extracted into a tiny private helper like
first_tag_id(filters) to avoid duplication; add a private function
first_tag_id/1 that returns that expression and replace the uses in
filter_assigns (currently computing first_tag_id) and in pagination_params to
call first_tag_id(filters), keeping behavior identical.

111-123: Missing @spec on several private helpers.

Per coding guidelines, every function (public and private) must have a @spec typespec. The following private functions lack one:

  • maybe_put_date/3 (line 111)
  • maybe_put_search/3 (line 121)
  • maybe_put/3 (line 158)
♻️ Example specs
+  `@spec` maybe_put_date(map(), atom(), String.t() | nil) :: map()
   defp maybe_put_date(map, _key, nil), do: map

+  `@spec` maybe_put_search(map(), atom(), String.t() | nil) :: map()
   defp maybe_put_search(map, _key, nil), do: map

+  `@spec` maybe_put(map(), String.t(), String.t() | nil) :: map()
   defp maybe_put(map, _key, nil), do: map

As per coding guidelines: "Every function (public and private) must have a @spec typespec".

Also applies to: 136-160

🤖 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/index.ex` around lines 111 - 123, Add
`@spec` annotations for the private helper functions: declare
maybe_put_date(map(), atom(), String.t() | nil) :: map(),
maybe_put_search(map(), atom(), String.t() | nil) :: map(), and maybe_put(map(),
atom(), any()) :: map() (or adjust the value type in maybe_put/3 to the precise
type used). Place each `@spec` immediately above the corresponding defp
(maybe_put_date/3, maybe_put_search/3, maybe_put/3) to satisfy the module's
typespec requirement.
lib/ksef_hub_web/live/tag_live.ex (1)

37-41: Prefer struct update syntax over Map.put/3 on a changeset.

%{changeset | action: :validate} is the idiomatic Elixir way to update a struct field and makes the intent clearer.

♻️ Proposed refactor
-    changeset =
-      changeset_for(socket.assigns.editing, params)
-      |> Map.put(:action, :validate)
+    changeset = %{changeset_for(socket.assigns.editing, params) | action: :validate}
🤖 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.ex` around lines 37 - 41, Replace the
Map.put/3 call with struct update syntax to set the changeset action: after
computing changeset via changeset_for(socket.assigns.editing, params) update the
action using the struct form (e.g. %{changeset | action: :validate}) instead of
Map.put so the intent is clearer and idiomatic; ensure you reference the
changeset variable produced by changeset_for/2 when making this change.
🤖 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.ex`:
- Around line 126-133: Change new_changeset to accept and use the real
company_id instead of the string "placeholder" (e.g. defp new_changeset(params
\\ %{}, company_id) and build %Tag{company_id: company_id} |>
Tag.changeset(...)) and add explicit `@specs` for both arities (0/1 and 2) so the
default-arg head is covered; update new_changeset_form and all call sites to
pass socket.assigns.company_id into new_changeset (e.g. new_changeset(%{},
socket.assigns.company_id)) so form-level validation and foreign key checks use
the actual company_id.

---

Outside diff comments:
In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 17-36: In mount/3 replace the unsafe dot-access of
socket.assigns.current_company with a safe fetch (e.g., company =
Map.get(socket.assigns, :current_company) or company =
socket.assigns[:current_company]) so accessing the missing key does not raise;
then continue to use that local company variable in the existing cond branch
that calls mount_invoice/3 and in the nil check branch so behavior remains
identical but safe on reconnects or missing assigns.

---

Duplicate comments:
In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 177-182: The changeset_message/1 now correctly pipes errors
through &translate_error/1, but ensure translate_error/1 is actually available
and used: import or alias the module that defines translate_error/1 (commonly
ErrorHelpers or your Web.ErrorHelpers) so the function resolves at compile time;
also normalize the field key in the Enum.map_join by converting k to a readable
string (handle atoms and binaries) before joining the error texts so the output
shows a humanized field name plus the interpolated messages (e.g., transform k
via Atom.to_string/1 or leave as-is if already a string, replace underscores and
capitalize).

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/index.ex`:
- Around line 42-55: The duplicated extraction of the first tag id
(filters[:tag_ids] |> List.wrap() |> List.first()) should be extracted into a
tiny private helper like first_tag_id(filters) to avoid duplication; add a
private function first_tag_id/1 that returns that expression and replace the
uses in filter_assigns (currently computing first_tag_id) and in
pagination_params to call first_tag_id(filters), keeping behavior identical.
- Around line 111-123: Add `@spec` annotations for the private helper functions:
declare maybe_put_date(map(), atom(), String.t() | nil) :: map(),
maybe_put_search(map(), atom(), String.t() | nil) :: map(), and maybe_put(map(),
atom(), any()) :: map() (or adjust the value type in maybe_put/3 to the precise
type used). Place each `@spec` immediately above the corresponding defp
(maybe_put_date/3, maybe_put_search/3, maybe_put/3) to satisfy the module's
typespec requirement.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 364-376: The input uses phx-keyup="new_tag_input" which updates
`@new_tag_name` on the server and re-renders the input value={`@new_tag_name`},
causing cursor flicker under latency; remove the real-time binding by deleting
phx-keyup="new_tag_input" (and any server handler new_tag_input if now unused)
so the field is only submitted via the form action "create_and_add_tag" and read
from params, leaving phx-debounce out or only on client-side handlers if you
later reintroduce live updates.
- Around line 66-99: Add missing `@doc` annotations for all public handle_event/3
clauses to match the existing pattern: each handle_event("approve", _params,
socket) and handle_event("reject", _params, socket) already have docs, so add
brief `@doc` strings above the other handle_event/3 implementations (the remaining
handle_event clauses in this module) describing the event handled and its
behavior (e.g., "Handles X action for invoices" or similar). Ensure every public
function (all handle_event/3 clauses) has a `@doc` line just above the `@impl` true
and before the def so the module follows the "every public function must have a
`@doc`" guideline.

In `@lib/ksef_hub_web/live/tag_live.ex`:
- Around line 37-41: Replace the Map.put/3 call with struct update syntax to set
the changeset action: after computing changeset via
changeset_for(socket.assigns.editing, params) update the action using the struct
form (e.g. %{changeset | action: :validate}) instead of Map.put so the intent is
clearer and idiomatic; ensure you reference the changeset variable produced by
changeset_for/2 when making this change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93bf2d8 and b4e85e6.

📒 Files selected for processing (4)
  • lib/ksef_hub_web/live/category_live.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
🚧 Files skipped from review as they are similar to previous changes (1)
  • lib/ksef_hub_web/live/category_live.ex

Comment thread lib/ksef_hub_web/live/tag_live.ex Outdated
- tag_live/category_live: replace "placeholder" company_id in
  new_changeset with real company_id from assigns; use idiomatic
  struct update (%{changeset | action: :validate}) instead of Map.put
- show.ex: use safe bracket access for socket.assigns[:current_company]
  consistent with [:current_user]; remove phx-keyup="new_tag_input"
  round-trips and use form id keying for input reset on submit
- index.ex: extract first_tag_id/1 helper to DRY duplicated expression;
  add missing @SPEC to maybe_put_date/3, maybe_put_search/3, maybe_put/3

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

@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: 6

🧹 Nitpick comments (1)
lib/ksef_hub_web/live/invoice_live/index.ex (1)

13-21: Consider pattern matching for clarity and safety.

Line 14 uses socket.assigns[:current_company] && socket.assigns.current_company.id, which works but is less idiomatic than extracting via pattern matching. A case or direct match would be clearer and avoid the double access.

♻️ Suggested refactor
  def mount(_params, _session, socket) do
-   company_id = socket.assigns[:current_company] && socket.assigns.current_company.id
-
-   {:ok,
-    assign(socket,
-      page_title: "Invoices",
-      categories: if(company_id, do: Invoices.list_categories(company_id), else: []),
-      all_tags: if(company_id, do: Invoices.list_tags(company_id), else: [])
-    )}
+   {categories, all_tags} =
+     case socket.assigns[:current_company] do
+       %{id: company_id} ->
+         {Invoices.list_categories(company_id), Invoices.list_tags(company_id)}
+       _ ->
+         {[], []}
+     end
+
+   {:ok,
+    assign(socket,
+      page_title: "Invoices",
+      categories: categories,
+      all_tags: all_tags
+    )}
  end

This mirrors the existing case pattern already used in handle_params (line 31), maintaining consistency within the module.

🤖 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/index.ex` around lines 13 - 21, In mount/3
replace the boolean-and access of socket.assigns to extract company_id with
explicit pattern matching or a case on socket.assigns (e.g., match
%{current_company: %Company{id: id}} or case socket.assigns do
%{current_company: %{id: id}} -> id; _ -> nil end) so you only access
current_company once and get a clear company_id value for building the assigns
passed to assign/2 (refer to mount/3 and the socket.assigns[:current_company]
usage in the diff).
🤖 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.ex`:
- Around line 69-80: The else branch currently treats any {:error, _} from
Invoices.get_category/2 the same, masking {:error, :not_found} (concurrent
deletion) as a deletion failure; change handle_event("delete", ...) to
explicitly match {:error, :not_found} and {:error, reason} separately — for
{:error, :not_found} remove the stale entry from the stream (call
stream_delete(:categories, id) or otherwise remove by id) and put a neutral info
flash like "Category not found or already deleted", and for {:error, _} keep the
existing put_flash(:error, "Failed to delete category.") behavior; keep the
successful {:ok, category} flow as-is.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 123-139: In handle_event("toggle_tag") validate the incoming
"tag-id" against socket.assigns.all_tags before calling tag_assigned?,
Invoices.add_invoice_tag or Invoices.remove_invoice_tag: first ensure the tag-id
exists in socket.assigns.all_tags (and normalize/convert its type as needed), if
it is invalid return {:noreply, put_flash(socket, :error, "Invalid tag")},
otherwise continue with the existing logic and call reload_details on success;
do not call Invoices.* functions for unknown/tampered IDs.
- Around line 104-118: The handle_event "set_category" should validate and
sanitize the incoming raw_id (from the raw_id param) before passing it to
Invoices.set_invoice_category — e.g. treat empty string as nil, parse numeric
ids to integers and reject/return an error flash for any malformed values — and
then handle the result of Invoices.mark_prediction_manual/1 instead of ignoring
it: after a successful {:ok, updated} from Invoices.set_invoice_category call,
call Invoices.mark_prediction_manual(updated) and pattern-match on its return
(handle {:ok, _} and {:error, reason}) to either proceed with assign(socket,
:invoice, reload_details(...)) or rollback/show a flash on failure; reference
the handle_event "set_category" function, the raw_id/category_id variables,
Invoices.set_invoice_category/2, Invoices.mark_prediction_manual/1, and
reload_details/2 when making these changes.

In `@lib/ksef_hub_web/live/tag_live.ex`:
- Around line 53-63: The edit handler currently assigns a raw Tag from
Invoices.get_tag/2 which lacks the select_merge-calculated usage_count, so when
update_tag/3 calls with_usage_count(updated, tag.usage_count) it passes nil and
resets usage to 0; fix by either A) after fetching tag in handle_event("edit",
...) load the usage_count from DB (e.g., call a new
Invoices.get_tag_with_usage_count/2 or re-run the list_tags select_merge for
that id) and assign that enriched struct to socket.assigns.editing, or B)
preserve the streamed entry’s usage_count by looking up the tag via the existing
list_tags query (which includes select_merge) when assigning editing; update
code locations: handle_event("edit", ...), Invoices.get_tag/2 usage, and ensure
update_tag/3 continues to call with_usage_count using the enriched tag.
- Around line 70-81: In handle_event("delete", %{"id" => id}, socket) within
TagLive (function handle_event), adjust the else clause to distinguish {:error,
:not_found} from other errors so that errors from Invoices.get_tag(company_id,
id) return put_flash(socket, :error, "Tag not found.") while other {:error, _}
still return "Failed to delete tag."; update pattern matching order in the else
(or refactor to case) to check for {:error, :not_found} before the generic
{:error, _}, and keep existing stream_delete/put_flash success path for
Invoices.delete_tag.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs`:
- Around line 140-148: The test "displays category name and emoji" currently
only asserts the category name; update it to also assert the emoji is rendered
by checking the rendered HTML or the category element for the emoji (use the
existing variables category and emoji created via insert(:category, ...), the
live view returned as view from live(conn, ~p"/invoices/#{invoice.id}"), and the
rendered HTML by render(view)); for example add an assertion that html contains
the emoji (e.g., assert html =~ "💰") or assert has_element?(view,
"[data-testid=category-select]", "💰") so the emoji rendering will be validated.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/index.ex`:
- Around line 13-21: In mount/3 replace the boolean-and access of socket.assigns
to extract company_id with explicit pattern matching or a case on socket.assigns
(e.g., match %{current_company: %Company{id: id}} or case socket.assigns do
%{current_company: %{id: id}} -> id; _ -> nil end) so you only access
current_company once and get a clear company_id value for building the assigns
passed to assign/2 (refer to mount/3 and the socket.assigns[:current_company]
usage in the diff).

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b4e85e6 and 94122b8.

📒 Files selected for processing (5)
  • lib/ksef_hub_web/live/category_live.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
  • test/ksef_hub_web/live/invoice_live/show_test.exs

Comment thread lib/ksef_hub_web/live/category_live.ex
Comment thread lib/ksef_hub_web/live/invoice_live/show.ex
Comment thread lib/ksef_hub_web/live/invoice_live/show.ex
Comment thread lib/ksef_hub_web/live/tag_live.ex
Comment thread lib/ksef_hub_web/live/tag_live.ex
Comment thread test/ksef_hub_web/live/invoice_live/show_test.exs
- Add get_tag_with_usage_count/2 to preserve usage_count on tag edit
- Distinguish :not_found from other errors in category/tag delete handlers
- Validate category_id as UUID and handle mark_prediction_manual result
- Validate tag-id against all_tags before toggling
- Add emoji assertion in show test
- Use pattern matching for company_id in index mount

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

@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 (2)
lib/ksef_hub_web/live/invoice_live/show.ex (1)

170-171: create_and_add_tag should be a single atomic context operation.

Right now, if tag creation succeeds and association fails, you keep a partially applied write (tag created, not attached). Consider moving this into one context function with transactional semantics and call that from the LiveView.

As per coding guidelines, "Use Ecto transactions and unique constraints for atomicity when performing multi-step database operations."

Also applies to: 179-185

🤖 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 170 - 171, The
two-step tag creation + association (Invoices.create_tag followed by
Invoices.add_invoice_tag) must be combined into a single transactional context
function (e.g., Invoices.create_and_add_tag/3) that wraps Repo.transaction,
creates the tag, inserts the association (add_invoice_tag logic) and enforces
unique constraints so both succeed or both roll back; then update the LiveView
call site in invoice_live/show.ex to call that new
Invoices.create_and_add_tag(invoice.id, company_id, %{name: name}) and handle
{:ok, result} / {:error, reason} responses instead of calling create_tag and
add_invoice_tag separately.
test/ksef_hub_web/live/invoice_live/show_test.exs (1)

146-148: Prefer selector assertions over raw render(view) string matching.

These checks are brittle and can pass on unrelated text. Use has_element?/2 / has_element?/3 with stable selectors for category, tag, and indicator assertions.

🔧 Example direction
- html = render(view)
- assert html =~ "finance:invoices"
- assert html =~ "💰"
+ assert has_element?(view, "[data-testid=category-select] option", "💰 finance:invoices")

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").

Also applies to: 157-158, 164-165, 181-183, 245-247

🤖 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 146 - 148,
Replace brittle string assertions that match raw HTML from render(view) with
selector-based assertions using Phoenix.LiveViewTest helpers: instead of calling
render(view) and asserting substring matches for the category
("finance:invoices"), the tag/icon ("💰"), etc., use element/2 or element/3 and
assert has_element?(view, selector) (e.g., assert has_element?(view,
"#invoice-category", "finance:invoices") or assert has_element?(view,
".invoice-tag", "💰")) so tests target stable DOM nodes; update the assertions
around render(view), the category/tag/indicator checks, and any similar
occurrences (including the other noted ranges) to use has_element?/element
selectors against the view variable.
🤖 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.ex`:
- Around line 141-143: The Integer.parse usage in the sort_order handling (case
Integer.parse(params["sort_order"] || "0") do ...) accepts partially-parsed
strings like "12abc"; change the match to require full consumption by matching
{n, ""} -> n and treat any other result (including {_, _} and :error) as 0;
update the same pattern wherever you parse params["sort_order"] in this LiveView
(CategoryLive) and audit other LiveView event handlers (*.live.ex) to validate
and fully-consume parsed numeric state changes before applying them.
- Around line 53-55: Validate the incoming "id" param in the LiveView before
calling the Invoices context: in handle_event("edit", %{"id" => id}, socket) and
handle_event("delete", %{"id" => id}, socket) first parse/validate id (e.g.,
Integer.parse/1 for numeric IDs or Ecto.UUID.cast/1 for UUIDs) and branch on the
result; if invalid, call put_flash(socket, :error, "Invalid category ID") (or
similar) and halt without invoking Invoices.get_category or
Invoices.delete_category, otherwise call the context with the
validated/converted id.

In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 348-364: The category (and new-tag) form in invoice_live/show.ex
currently renders a raw <form> and <select> (phx-change="set_category") instead
of using the project's LiveView form convention; update mount and relevant
handle_event(s) (e.g., mount and the "set_category" handler) to build and assign
a to_form/2-driven assign (e.g., `@category_form` and `@new_tag_form`, following
tag_live.ex), then replace the raw <form> with the component-style <.form
for={`@category_form`} phx-change="set_category"> and bind the select to
`@category_form`[:category_id] (and similarly use `@new_tag_form`[:field] for tags)
so the template uses the managed form assigns and field helpers consistently
with the codebase.

In `@lib/ksef_hub_web/live/tag_live.ex`:
- Around line 54-56: Validate the incoming id in the "edit" and "delete" event
handlers using Ecto.UUID.cast() before calling Invoices functions: call
Ecto.UUID.cast(id) in the handle_event("edit", %{"id" => id}, socket) path
(which currently calls Invoices.get_tag_with_usage_count) and in the
handle_event("delete", %{"id" => id}, socket) path (which calls
Invoices.delete_tag or similar), branch on the cast result and when it returns
:error push an error flash to the socket and halt further processing; only pass
the cast UUID to the Invoices functions when cast returns {:ok, uuid}. Ensure
you reference the existing handler functions ("edit" and "delete") and the
context calls (Invoices.get_tag_with_usage_count / Invoices.delete_tag) when
making the change.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 170-171: The two-step tag creation + association
(Invoices.create_tag followed by Invoices.add_invoice_tag) must be combined into
a single transactional context function (e.g., Invoices.create_and_add_tag/3)
that wraps Repo.transaction, creates the tag, inserts the association
(add_invoice_tag logic) and enforces unique constraints so both succeed or both
roll back; then update the LiveView call site in invoice_live/show.ex to call
that new Invoices.create_and_add_tag(invoice.id, company_id, %{name: name}) and
handle {:ok, result} / {:error, reason} responses instead of calling create_tag
and add_invoice_tag separately.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs`:
- Around line 146-148: Replace brittle string assertions that match raw HTML
from render(view) with selector-based assertions using Phoenix.LiveViewTest
helpers: instead of calling render(view) and asserting substring matches for the
category ("finance:invoices"), the tag/icon ("💰"), etc., use element/2 or
element/3 and assert has_element?(view, selector) (e.g., assert
has_element?(view, "#invoice-category", "finance:invoices") or assert
has_element?(view, ".invoice-tag", "💰")) so tests target stable DOM nodes;
update the assertions around render(view), the category/tag/indicator checks,
and any similar occurrences (including the other noted ranges) to use
has_element?/element selectors against the view variable.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 94122b8 and 05f05d7.

📒 Files selected for processing (6)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub_web/live/category_live.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
  • test/ksef_hub_web/live/invoice_live/show_test.exs

Comment thread lib/ksef_hub_web/live/category_live.ex Outdated
Comment thread lib/ksef_hub_web/live/category_live.ex Outdated
Comment thread lib/ksef_hub_web/live/invoice_live/show.ex Outdated
Comment thread lib/ksef_hub_web/live/tag_live.ex Outdated
- Validate incoming IDs with Ecto.UUID.cast before calling context in
  category and tag LiveView edit/delete handlers
- Fix Integer.parse sort_order to require full consumption ({n, ""})
- Convert raw <form> to <.form> with managed assigns in show.ex
  (category_form, new_tag_form) for consistency with codebase conventions
- Add transactional Invoices.create_and_add_tag/3 to atomically create
  a tag and associate it with an invoice

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

@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

🤖 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/show.ex`:
- Around line 138-154: In the handle_event "toggle_tag" code path (the function
handling tag toggles in invoice_live/show.ex), after a successful tag addition
or removal (the {:ok, _} branch where you call Invoices.add_invoice_tag/3 or
Invoices.remove_invoice_tag/2 and then reload details), also call
Invoices.mark_prediction_manual(invoice.id) so the invoice prediction is marked
manual; update both occurrences of this success branch (the other similar block
around the second toggle handler) to call Invoices.mark_prediction_manual/1
before assigning the reloaded invoice to socket and returning {:noreply, ...}.
- Around line 111-116: The reload is happening before updating the manual
prediction status, causing stale prediction_status in assigns; change the flow
in the show LiveView so you call Invoices.mark_prediction_manual(updated) first
and only on {:ok, _} call reload_details(updated, socket) and then assign the
freshly reloaded invoice and category_form(reloaded) to socket (update the block
around Invoices.mark_prediction_manual/1, reload_details/2, assign/2 and ensure
prediction_status from the reloaded invoice is used).
- Around line 40-44: mount_invoice/3 currently passes the raw route id into
Invoices.get_invoice_with_details which can raise Ecto.CastError for malformed
IDs; validate the id first by using Ecto.UUID.cast(id) (same approach as
validate_category_id/1) and if the cast returns :error or nil perform the same
graceful redirect/handle_error flow you use elsewhere (assigning flash/redirect
or returning {:halt, socket} as appropriate), otherwise call
Invoices.get_invoice_with_details with the validated UUID; reference
mount_invoice/3, Invoices.get_invoice_with_details and Ecto.UUID.cast/1 when
making the change.

In `@lib/ksef_hub/invoices.ex`:
- Around line 788-794: In create_and_add_tag/3 wrap the add flow with an
explicit invoice/company consistency check: inside the Repo.transaction block
(where create_tag and add_invoice_tag are called) load the invoice by invoice_id
(e.g., via Repo.get or the existing get_invoice function), verify its company_id
matches the caller-provided company_id, and if not return {:error,
:invoice_company_mismatch} (or Repo.rollback with a clear reason) before calling
add_invoice_tag; keep the existing create_tag and add_invoice_tag calls and only
proceed when the invoice belongs to the same company.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 05f05d7 and 084bb26.

📒 Files selected for processing (4)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub_web/live/category_live.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • lib/ksef_hub_web/live/tag_live.ex

Comment on lines +40 to +44
defp mount_invoice(socket, company, id) do
role = socket.assigns[:current_role]

case Invoices.get_invoice_with_details(company.id, id, role: role) do
nil ->

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

🧩 Analysis chain

🏁 Script executed:

# First, verify the file exists and check the actual code around line 40-44
if [ -f "lib/ksef_hub_web/live/invoice_live/show.ex" ]; then
  echo "=== File exists ===" 
  wc -l lib/ksef_hub_web/live/invoice_live/show.ex
  echo "=== Lines 35-50 ===" 
  sed -n '35,50p' lib/ksef_hub_web/live/invoice_live/show.ex | cat -n
else
  echo "File not found"
fi

Repository: appunite/appunite-ksef-ex

Length of output: 715


🏁 Script executed:

# Check the Invoices module to understand get_invoice_with_details signature
fd -t f "invoices\.ex" --exec grep -l "get_invoice_with_details" {} \;

Repository: appunite/appunite-ksef-ex

Length of output: 95


🏁 Script executed:

# Search for the exact function definition
rg "def get_invoice_with_details" -A 5

Repository: appunite/appunite-ksef-ex

Length of output: 843


🏁 Script executed:

# Check the Invoice schema to see what type the id field is
fd -t f "invoice\.ex" | grep -E "schemas|models" | head -5

Repository: appunite/appunite-ksef-ex

Length of output: 104


🏁 Script executed:

# Search for Invoice schema definition
rg "schema \"invoices\"" -A 10

Repository: appunite/appunite-ksef-ex

Length of output: 794


🏁 Script executed:

# Check if id is explicitly defined as UUID in the schema
rg "field :id" lib/ksef_hub/invoices/ -B 2 -A 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Check how Phoenix/Ecto handles UUID casting in queries
rg "Ecto\.UUID\.cast"

Repository: appunite/appunite-ksef-ex

Length of output: 875


🏁 Script executed:

# Look for similar UUID validation patterns in the codebase
rg "Ecto\.UUID" -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 50381


🏁 Script executed:

# Check if there are any tests for mount_invoice with invalid UUIDs
rg "mount_invoice" test/ -A 5 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Verify Ecto's casting behavior in queries by checking any error handling
rg "CastError\|cast.*error" lib/ test/ -B 2 -A 2 | head -50

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Check the actual route definition to confirm id is a string param
rg "invoice_live.*show" lib/ksef_hub_web/router.ex -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Verify that Ecto implicitly casts query parameters or if explicit cast is needed
rg "def get_invoice_with_details" lib/ksef_hub/invoices.ex -A 8

Repository: appunite/appunite-ksef-ex

Length of output: 740


🏁 Script executed:

# Verify the route definition
fd router.ex | xargs cat | rg "invoice.*show" -A 3 -B 3

Repository: appunite/appunite-ksef-ex

Length of output: 640


🏁 Script executed:

# Search for live view routes specifically
rg "invoice_live.*show|live.*invoice.*show" lib/ksef_hub_web/router.ex -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Look for all invoice_live routes
rg "invoice_live" lib/ksef_hub_web/router.ex -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


🏁 Script executed:

# Check the mount function of the show.ex live view to see how id parameter flows in
sed -n '1,50p' lib/ksef_hub_web/live/invoice_live/show.ex | cat -n

Repository: appunite/appunite-ksef-ex

Length of output: 1773


🏁 Script executed:

# Search for where this LiveView is mounted
rg "invoice_live/show" lib/ -A 2 -B 2

Repository: appunite/appunite-ksef-ex

Length of output: 51


Validate the invoice ID before querying in mount_invoice/3.

Passing the raw route parameter directly to get_invoice_with_details can cause an Ecto.CastError when the ID is malformed, crashing the mount instead of redirecting gracefully. The same file already uses Ecto.UUID.cast/1 for this purpose in validate_category_id/1.

🔧 Suggested fix
 defp mount_invoice(socket, company, id) do
   role = socket.assigns[:current_role]

-  case Invoices.get_invoice_with_details(company.id, id, role: role) do
-    nil ->
-      {:ok,
-       socket
-       |> put_flash(:error, "Invoice not found.")
-       |> redirect(to: ~p"/invoices")}
-
-    invoice ->
-      {:ok,
-       socket
-       |> assign(
-         page_title: "Invoice #{invoice.invoice_number}",
-         invoice: invoice,
-         html_preview: generate_preview(invoice),
-         categories: Invoices.list_categories(company.id),
-         all_tags: Invoices.list_tags(company.id),
-         category_form: category_form(invoice),
-         new_tag_form: new_tag_form(),
-         tag_form_key: 0
-       )}
+  with {:ok, uuid} <- Ecto.UUID.cast(id) do
+    case Invoices.get_invoice_with_details(company.id, uuid, role: role) do
+      nil ->
+        {:ok,
+         socket
+         |> put_flash(:error, "Invoice not found.")
+         |> redirect(to: ~p"/invoices")}
+
+      invoice ->
+        {:ok,
+         socket
+         |> assign(
+           page_title: "Invoice #{invoice.invoice_number}",
+           invoice: invoice,
+           html_preview: generate_preview(invoice),
+           categories: Invoices.list_categories(company.id),
+           all_tags: Invoices.list_tags(company.id),
+           category_form: category_form(invoice),
+           new_tag_form: new_tag_form(),
+           tag_form_key: 0
+         )}
+    end
+  else
+    :error ->
+      {:ok,
+       socket
+       |> put_flash(:error, "Invalid invoice ID.")
+       |> redirect(to: ~p"/invoices")}
   end
 end
📝 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 mount_invoice(socket, company, id) do
role = socket.assigns[:current_role]
case Invoices.get_invoice_with_details(company.id, id, role: role) do
nil ->
defp mount_invoice(socket, company, id) do
role = socket.assigns[:current_role]
with {:ok, uuid} <- Ecto.UUID.cast(id) do
case Invoices.get_invoice_with_details(company.id, uuid, role: role) do
nil ->
{:ok,
socket
|> put_flash(:error, "Invoice not found.")
|> redirect(to: ~p"/invoices")}
invoice ->
{:ok,
socket
|> assign(
page_title: "Invoice #{invoice.invoice_number}",
invoice: invoice,
html_preview: generate_preview(invoice),
categories: Invoices.list_categories(company.id),
all_tags: Invoices.list_tags(company.id),
category_form: category_form(invoice),
new_tag_form: new_tag_form(),
tag_form_key: 0
)}
end
else
:error ->
{:ok,
socket
|> put_flash(:error, "Invalid invoice ID.")
|> redirect(to: ~p"/invoices")}
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/invoice_live/show.ex` around lines 40 - 44,
mount_invoice/3 currently passes the raw route id into
Invoices.get_invoice_with_details which can raise Ecto.CastError for malformed
IDs; validate the id first by using Ecto.UUID.cast(id) (same approach as
validate_category_id/1) and if the cast returns :error or nil perform the same
graceful redirect/handle_error flow you use elsewhere (assigning flash/redirect
or returning {:halt, socket} as appropriate), otherwise call
Invoices.get_invoice_with_details with the validated UUID; reference
mount_invoice/3, Invoices.get_invoice_with_details and Ecto.UUID.cast/1 when
making the change.

Comment on lines +111 to +116
reloaded = reload_details(updated, socket)

case Invoices.mark_prediction_manual(updated) do
{:ok, _} ->
{:noreply, assign(socket, invoice: reloaded, category_form: category_form(reloaded))}

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

Reload occurs before manual-status update, leaving stale prediction_status in assigns.

Line 111 reloads first, then Line 113 updates prediction status. On success, the assigned invoice can still show the old status.

🔧 Suggested fix
-    with :ok <- validate_category_id(category_id),
-         {:ok, updated} <- Invoices.set_invoice_category(socket.assigns.invoice, category_id) do
-      reloaded = reload_details(updated, socket)
-
-      case Invoices.mark_prediction_manual(updated) do
-        {:ok, _} ->
-          {:noreply, assign(socket, invoice: reloaded, category_form: category_form(reloaded))}
+    with :ok <- validate_category_id(category_id),
+         {:ok, updated} <- Invoices.set_invoice_category(socket.assigns.invoice, category_id) do
+      case Invoices.mark_prediction_manual(updated) do
+        {:ok, _} ->
+          reloaded = reload_details(updated, socket)
+          {:noreply, assign(socket, invoice: reloaded, category_form: category_form(reloaded))}
🤖 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 111 - 116, The
reload is happening before updating the manual prediction status, causing stale
prediction_status in assigns; change the flow in the show LiveView so you call
Invoices.mark_prediction_manual(updated) first and only on {:ok, _} call
reload_details(updated, socket) and then assign the freshly reloaded invoice and
category_form(reloaded) to socket (update the block around
Invoices.mark_prediction_manual/1, reload_details/2, assign/2 and ensure
prediction_status from the reloaded invoice is used).

Comment on lines +138 to +154
def handle_event("toggle_tag", %{"tag-id" => tag_id}, socket) do
if Enum.any?(socket.assigns.all_tags, &(&1.id == tag_id)) do
invoice = socket.assigns.invoice
currently_assigned = tag_assigned?(invoice, tag_id)

result =
if currently_assigned,
do: Invoices.remove_invoice_tag(invoice.id, tag_id),
else: Invoices.add_invoice_tag(invoice.id, tag_id, invoice.company_id)

case result do
{:ok, _} ->
{:noreply, assign(socket, :invoice, reload_details(invoice, socket))}

{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to update tags.")}
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

Tag mutations should also mark prediction status as manual.

Both tag success paths change classification but never call Invoices.mark_prediction_manual/1, so the invoice may remain in a non-manual prediction state after user overrides.

🔧 Suggested fix
       case result do
         {:ok, _} ->
-          {:noreply, assign(socket, :invoice, reload_details(invoice, socket))}
+          _ = Invoices.mark_prediction_manual(invoice)
+          {:noreply, assign(socket, :invoice, reload_details(invoice, socket))}

         {:error, _} ->
           {:noreply, put_flash(socket, :error, "Failed to update tags.")}
       end
@@
     case Invoices.create_and_add_tag(invoice.id, company_id, %{name: name}) do
       {:ok, _tag} ->
+        _ = Invoices.mark_prediction_manual(invoice)
         {:noreply,
          socket
          |> assign(
            invoice: reload_details(invoice, socket),

Also applies to: 174-183

🤖 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 138 - 154, In the
handle_event "toggle_tag" code path (the function handling tag toggles in
invoice_live/show.ex), after a successful tag addition or removal (the {:ok, _}
branch where you call Invoices.add_invoice_tag/3 or
Invoices.remove_invoice_tag/2 and then reload details), also call
Invoices.mark_prediction_manual(invoice.id) so the invoice prediction is marked
manual; update both occurrences of this success branch (the other similar block
around the second toggle handler) to call Invoices.mark_prediction_manual/1
before assigning the reloaded invoice to socket and returning {:noreply, ...}.

Comment thread lib/ksef_hub/invoices.ex
Comment on lines +788 to +794
Repo.transaction(fn ->
with {:ok, tag} <- create_tag(company_id, attrs),
{:ok, _it} <- add_invoice_tag(invoice_id, tag.id, company_id) do
tag
else
{:error, reason} -> Repo.rollback(reason)
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

Enforce invoice/company consistency inside create_and_add_tag/3.

Line 790 uses caller-provided company_id to validate tag ownership, but never verifies that invoice_id belongs to the same company. This can create cross-company tag associations if mismatched IDs reach this API.

🔧 Suggested fix
 def create_and_add_tag(invoice_id, company_id, attrs) do
   Repo.transaction(fn ->
-    with {:ok, tag} <- create_tag(company_id, attrs),
+    with true <-
+           Invoice
+           |> where([i], i.id == ^invoice_id and i.company_id == ^company_id)
+           |> Repo.exists?(),
+         {:ok, tag} <- create_tag(company_id, attrs),
          {:ok, _it} <- add_invoice_tag(invoice_id, tag.id, company_id) do
       tag
     else
+      false -> Repo.rollback(:invoice_not_in_company)
       {:error, reason} -> Repo.rollback(reason)
     end
   end)
 end
📝 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
Repo.transaction(fn ->
with {:ok, tag} <- create_tag(company_id, attrs),
{:ok, _it} <- add_invoice_tag(invoice_id, tag.id, company_id) do
tag
else
{:error, reason} -> Repo.rollback(reason)
end
Repo.transaction(fn ->
with true <-
Invoice
|> where([i], i.id == ^invoice_id and i.company_id == ^company_id)
|> Repo.exists?(),
{:ok, tag} <- create_tag(company_id, attrs),
{:ok, _it} <- add_invoice_tag(invoice_id, tag.id, company_id) do
tag
else
false -> Repo.rollback(:invoice_not_in_company)
{:error, reason} -> Repo.rollback(reason)
end
end)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 788 - 794, In create_and_add_tag/3
wrap the add flow with an explicit invoice/company consistency check: inside the
Repo.transaction block (where create_tag and add_invoice_tag are called) load
the invoice by invoice_id (e.g., via Repo.get or the existing get_invoice
function), verify its company_id matches the caller-provided company_id, and if
not return {:error, :invoice_company_mismatch} (or Repo.rollback with a clear
reason) before calling add_invoice_tag; keep the existing create_tag and
add_invoice_tag calls and only proceed when the invoice belongs to the same
company.

@emilwojtaszek emilwojtaszek merged commit a745d0c into main Feb 25, 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