Skip to content

Feat/reviewer expense only#43

Merged
emilwojtaszek merged 8 commits into
mainfrom
feat/reviewer-expense-only
Feb 15, 2026
Merged

Feat/reviewer expense only#43
emilwojtaszek merged 8 commits into
mainfrom
feat/reviewer-expense-only

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 15, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Role-based invoice visibility: reviewers can view only expense invoices; UI, API and downloads respect this restriction and the Type filter is locked for reviewers.
    • API and session flows now propagate the current role to back-end lookups so views reflect role scoping.
  • Documentation

    • Added ADR describing reviewer expense-only visibility and enforcement across layers.
  • Tests

    • Expanded role-based scoping test coverage (includes reviewer behavior); some test blocks were duplicated.
  • Chores

    • Added shared role-resolution helper used by auth and live layers.

@coderabbitai

coderabbitai Bot commented Feb 15, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Implements role-based access restricting reviewers to expense invoices by propagating a role value through authentication, controllers, LiveView, and the invoices context; the context layer enforces filtering (expense-only) for reviewer role and tests/UI updated accordingly.

Changes

Cohort / File(s) Summary
Architecture Decision
docs/adr/0016-reviewer-expense-only-visibility.md
New ADR documenting reviewer expense-only visibility, enforcement locations, permission matrix, token scope implications, and OpenAPI notes.
Context Layer Role Filtering
lib/ksef_hub/invoices.ex
Public invoice APIs (list/count/paginate/get/get!) accept opts and apply scope_by_role/2 / maybe_scope_type_by_role/2 to restrict queries to type: "expense" when role is "reviewer".
Authentication & Role Resolution
lib/ksef_hub_web/helpers/auth_helpers.ex, lib/ksef_hub_web/live/live_auth.ex, lib/ksef_hub_web/plugs/api_auth.ex
Adds resolve_role/2 helper module; LiveAuth imports it; API auth plug computes and assigns :current_role from token creator's membership.
Controllers & Downloads
lib/ksef_hub_web/controllers/api/invoice_controller.ex, lib/ksef_hub_web/controllers/invoice_pdf_controller.ex
Threads role: role (from assigns/resolve_role) into invoice retrieval calls (index/show/approve/reject/html/xml/pdf) to enforce context-layer scoping.
LiveView UI & Data
lib/ksef_hub_web/live/dashboard_live.ex, lib/ksef_hub_web/live/invoice_live/index.ex, lib/ksef_hub_web/live/invoice_live/show.ex
Passes role into context queries, adds is_reviewer assign, hides Income stat and disables Type filter for reviewers, and conditions rendered UI on reviewer status.
Tests & Test Helpers
test/ksef_hub/invoices_test.exs, test/ksef_hub_web/controllers/api/invoice_controller_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/plugs/api_auth_test.exs, test/support/api_test_helpers.ex
Adds reviewer-scoping tests across unit, controller, live tests and API auth tests; adds create_reviewer_with_token/1 test helper. (Note: some test blocks appear duplicated in a few files.)

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as API Controller
    participant Auth as API Auth Plug
    participant Live as LiveView/Controller
    participant Context as KsefHub.Invoices
    participant DB as Database

    rect rgba(200,200,255,0.5)
    Client->>API: Request (with token / session)
    API->>Auth: validate_token()
    Auth->>Auth: resolve_role(user_id, company_id)
    Auth-->>API: assigns :current_role (e.g., "reviewer")
    end

    rect rgba(200,255,200,0.5)
    API->>Context: list/get invoices (company_id, filters, role: "reviewer")
    Context->>Context: maybe_scope_type_by_role(role)
    Context->>DB: query invoices WHERE company_id AND (type = "expense" if reviewer)
    DB-->>Context: rows
    Context-->>API: invoices
    API-->>Client: response (filtered)
    end

    rect rgba(255,230,200,0.5)
    Client->>Live: LiveView request
    Live->>Auth: resolve_role(user_id, company_id)
    Live->>Context: list_invoices_paginated(..., role: "reviewer")
    Context->>DB: query (scoped)
    DB-->>Context: rows
    Context-->>Live: paginated result
    Live-->>Client: UI (type filter disabled, income hidden)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Feat/invoice pagination #37 — Related pagination and invoice-list API changes that this PR extends by adding role-aware opts handling.
  • Feat/memberships rbac #29 — Related role resolution / membership handling and LiveAuth propagation patterns reused here.
  • Feat/multi company support #21 — Prior changes to KsefHub.Invoices signatures and scoping that overlap with the updated function arities and query constraints.

Poem

🐰 I nibbled through roles, found "reviewer" small and spry,

"Expenses only," I whispered — income waved goodbye.
Through auth and context my hops were spread,
Now dashboards hide what reviewers shouldn't tread.
Hooray! 🥕📄

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title 'Feat/reviewer expense only' is vague and abbreviated. While it hints at reviewer-related features and expense filtering, it lacks clarity and uses non-standard formatting with 'Feat/' prefix and lacks proper sentence structure. Consider using a clearer, more descriptive title such as 'Restrict reviewer role to viewing expense invoices only' that explicitly describes the main change without abbreviations.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 96.00% which is sufficient. The required threshold is 80.00%.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/reviewer-expense-only

No actionable comments were generated in the recent review. 🎉


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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub_web/controllers/invoice_pdf_controller.ex`:
- Around line 50-62: The resolve_role/2 function in invoice_pdf_controller.ex
duplicates logic found elsewhere; extract this into a shared helper module
(e.g., AuthHelpers.resolve_role/2) that takes the Plug.Conn and company_id,
calls conn.assigns[:current_user] and Companies.get_membership(user_id,
company_id) and returns the role or nil, then replace the local resolve_role/2
implementation in InvoicePdfController (and other modules) with a call to the
new shared function to centralize the logic and avoid duplication.

In `@lib/ksef_hub_web/live/invoice_live/index.ex`:
- Around line 153-163: Replace the raw <form> and direct assigns with the
Phoenix HTML form helper pattern: in the live view assign function (e.g.,
filter_assigns/3) build a form with form = to_form(filters, as: :filters) and
include it in assigns (form: form) instead of raw filter fields; then update the
template to use the component <.form for={`@form`} phx-change="filter" ...> and
replace direct name/selected usage with `@form`[:type].name, `@form`[:type].id and
`@form`[:type].value for each select/input (handle the reviewer-disabled select by
using the same `@form`[:type] attributes plus disabled). Ensure all other filter
fields follow the same `@form`[:field] attribute access.

In `@test/support/api_test_helpers.ex`:
- Around line 41-70: The create_reviewer_with_token function should use a with
chain to safely sequence Accounts.create_api_token and the membership role
downgrade instead of calling update!; replace the current direct pattern-match
and update! with a with that calls Accounts.create_api_token(user.id,
company.id, ...) and then Ecto.Changeset.change(membership, role: "reviewer") |>
KsefHub.Repo.update, propagating {:ok, ...} and {:error, reason} cases; update
the function spec/signature to return {:ok, %{user: ..., company: ..., token:
..., api_token: ...}} | {:error, term()} so callers can handle failures.
🧹 Nitpick comments (2)
lib/ksef_hub_web/plugs/api_auth.ex (1)

49-55: Extract role resolution into a shared helper.

resolve_role/2 duplicates logic already present in KsefHubWeb.LiveAuth and InvoicePdfController. Please centralize this in a shared module (e.g., KsefHub.Companies or a KsefHubWeb.Auth helper) and call it from each place to avoid drift.
As per coding guidelines: “Extract shared logic into dedicated modules instead of copy-pasting.”

test/ksef_hub_web/live/invoice_live/show_test.exs (1)

128-160: Move default stub to the describe setup.

These tests use stub/3 for a default return; per guideline, place that in the setup block for the reviewer describe to keep the test bodies focused.

♻️ Suggested refactor
 describe "reviewer role" do
   setup %{conn: _conn} do
@@
     conn = build_conn() |> log_in_user(reviewer, %{current_company_id: company.id})
-    %{conn: conn, company: company}
+    stub(KsefHub.Pdf.Mock, :generate_html, fn _xml, _meta -> {:error, :no_xml} end)
+    %{conn: conn, company: company}
   end
@@
-    stub(KsefHub.Pdf.Mock, :generate_html, fn _xml, _meta -> {:error, :no_xml} end)
-
     {:ok, _view, html} = live(conn, ~p"/invoices/#{invoice.id}")
@@
-    stub(KsefHub.Pdf.Mock, :generate_html, fn _xml, _meta -> {:error, :no_xml} end)
-
     assert {:error, {:redirect, %{to: "/invoices"}}} =
              live(conn, ~p"/invoices/#{invoice.id}")

As per coding guidelines: “Use expect/3 for specific call expectations in tests; use stub/3 for default returns in setup blocks.”

Comment thread lib/ksef_hub_web/controllers/invoice_pdf_controller.ex Outdated
Comment thread lib/ksef_hub_web/live/invoice_live/index.ex Outdated
Comment thread test/support/api_test_helpers.ex
emilwojtaszek and others added 6 commits February 15, 2026 18:46
Reviewers should not see income invoices. Defines enforcement at the
context layer with role passed from web layer, covering LiveView, API,
and download endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add role-aware filtering to the Invoices context layer so all access
paths (LiveView, API, browser downloads) consistently restrict reviewers
to expense invoices. The context accepts an optional `role:` keyword;
when "reviewer", it forces `type: "expense"` on list queries and adds
a type constraint on single-invoice lookups.

- Invoices context: add opts keyword with :role to list/get functions
- API auth plug: resolve current_role from token creator's membership
- LiveView index/show: pass current_role to context calls
- API controller: pass current_role to all context calls
- PDF controller: resolve role from session user's membership
- Dashboard: hide income stats for reviewers

Implements ADR 0016.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover all access paths for the reviewer-only-expense constraint:
- Context: list, count, get, get! with role: "reviewer"
- LiveView index: reviewer sees only expense, type=income param ignored
- LiveView show: reviewer redirected on income invoice
- API controller: reviewer token scoped to expense invoices
- API auth plug: current_role resolved from token creator's membership

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add default xml_content to invoice factory to satisfy NOT NULL constraint
- Fix create_reviewer_with_token to create as owner first then downgrade,
  since token creation requires owner role

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The xml_content field was defined twice — a stale "<Faktura/>" stub
at the top and the correct File.read! fixture further down. Since
Elixir maps take the last key, the stub was silently ignored. Remove
it to avoid confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract resolve_role/2 into KsefHubWeb.AuthHelpers shared module,
  removing duplicate private functions from InvoicePdfController,
  Plugs.ApiAuth, and LiveAuth
- Replace raw HTML form with Phoenix to_form in InvoiceLive.Index
  filter form, removing individual filter assigns in favor of @Form
- Refactor create_reviewer_with_token to use with chain and
  Repo.update instead of Repo.update!, returning {:ok, map} tuples
- Move Pdf.Mock stub into reviewer describe setup block in show_test
- Resolve merge conflict in invoice_controller_test.exs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek force-pushed the feat/reviewer-expense-only branch from 455ddd2 to d36daa5 Compare February 15, 2026 17:59

@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.

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/controllers/api/invoice_controller.ex (1)

262-267: ⚠️ Potential issue | 🟠 Major

Missing role parameter in xml action creates authorization bypass.

The xml action does not pass role to get_invoice!/3, unlike all other actions (show, approve, reject, html, pdf). This allows reviewers to download XML content of income invoices they shouldn't have access to.

🔒 Proposed fix to add role parameter
 def xml(conn, %{"id" => id}) do
   company_id = conn.assigns.current_company.id
-  invoice = Invoices.get_invoice!(company_id, id)
+  invoice = Invoices.get_invoice!(company_id, id, role: conn.assigns[:current_role])

   send_attachment(conn, "application/xml", "#{invoice.invoice_number}.xml", invoice.xml_content)
 end
🧹 Nitpick comments (3)
docs/adr/0016-reviewer-expense-only-visibility.md (1)

35-35: Update function arities in documentation.

The ADR references list_invoices_paginated/2 and get_invoice/2, but the implementation uses 3-arity versions with an opts keyword list parameter. Consider updating to reflect the actual API:

-1. **Context layer (`KsefHub.Invoices`)** — `list_invoices_paginated/2` and `get_invoice/2` accept a `role` parameter.
+1. **Context layer (`KsefHub.Invoices`)** — `list_invoices_paginated/3` and `get_invoice/3` accept a `role` option via the `opts` keyword list.
lib/ksef_hub_web/live/invoice_live/index.ex (1)

171-178: Disabled select won't submit value — verify filter behavior.

A disabled <select> element doesn't submit its value with the form. When a reviewer triggers the phx-change="filter" event, params["type"] will be nil or missing. This should be fine since scope_by_role/2 in the context layer enforces type: "expense" regardless, but the filter URL won't include type=expense.

If you want the URL to explicitly show ?type=expense for reviewers (for clarity/bookmarking), consider using readonly styling with a hidden input instead, or simply accept the current behavior where the backend enforces the constraint silently.

lib/ksef_hub/invoices.ex (1)

73-88: Consider passing opts to internal count_invoices call for consistency.

On line 78, count_invoices(company_id, filters) is called without the opts (including role). This works because filters is already scoped by scope_by_role/2 on line 74, and calling count_invoices/3 with default opts applies scope_by_role(filters, nil) which is a no-op.

However, for clarity and to avoid subtle bugs if scope_by_role behavior changes in the future, consider explicitly passing the same opts:

-    total_count = count_invoices(company_id, filters)
+    total_count = count_invoices(company_id, filters, opts)

This would technically apply scope_by_role twice (idempotent), but makes the intent clearer.

emilwojtaszek and others added 2 commits February 15, 2026 19:04
Reviewers could download XML of income invoices via the xml endpoint
because it was the only action not passing the role option to
get_invoice!/3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Update ADR 0016 to reference /3 arities with opts keyword list
- Pass opts through to count_invoices in list_invoices_paginated for
  consistency, ensuring role scoping is explicit at every call site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 380502c into main Feb 15, 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