Skip to content

Feat/company invitations#32

Merged
emilwojtaszek merged 13 commits into
mainfrom
feat/company-invitations
Feb 11, 2026
Merged

Feat/company invitations#32
emilwojtaszek merged 13 commits into
mainfrom
feat/company-invitations

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 10, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features
    • Owner-only Team UI to invite, list, update and remove members; "Team" sidebar link
    • Token-based invitations with emailed accept links, expiry, accept/cancel flows, and notifier
    • Auto-accept pending invitations on signup or Google sign-in; invitation accept page handles invalid/expired/already-member states
    • Safer return_to redirect sanitization
  • Migrations
    • New invitations table and indices (including email index)
  • Documentation
    • Implementation plan status updates
  • Tests
    • Extensive tests for companies, invitations, notifier, LiveViews, and auth return_to handling

emilwojtaszek and others added 8 commits February 10, 2026 23:30
Create invitations table with hashed token, status enum, 7-day expiry,
and unique partial index on (company_id, email) for pending invitations.
Add Invitation schema with changeset validations (invitable roles only,
email normalization) and :invitation factory.

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

Implements create_invitation/3 (owner-only, hashed token), accept_invitation/2
(token verification, membership creation via Multi), cancel_invitation/2,
list_pending_invitations/1, and accept_pending_invitations_for_email/1 for
auto-accepting on sign-up. All functions enforce authorization and expiry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Delivers invitation emails via Swoosh with accept URL, company name,
and role. Follows the same pattern as UserNotifier for confirmation
and password reset emails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
InvitationAcceptLive validates token and accepts if logged in, or
redirects to login with return_to param if not. Add return_to support
to UserLoginLive (hidden field), UserSessionController (param pass-through),
and UserAuth.log_in_user (sanitized redirect). Open-redirect protection
validates path-only URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Call Invitations.accept_pending_invitations_for_email/1 after user
registration (UserRegistrationLive) and Google sign-in (AuthController)
to automatically grant company access for pending invitations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Owner-only TeamLive page for viewing members, sending invitations,
cancelling pending invitations, and removing members. Add list_members/1,
delete_membership/1, and update_membership_role/2 to Companies context.
Add Team nav link (owner-only) to sidebar layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use Company.t() instead of map() in send_invitation_email spec
- Log email delivery failures instead of silently discarding
- Fix misplaced @SPEC render above defp sanitize_return_to
- Add {:error, Ecto.Changeset.t()} to do_accept_invitation spec
- Replace html =~ with has_element? using CSS selectors in tests
- Add mass-assignment test for Invitation changeset
- Add open-redirect tests for sanitize_return_to
- Add cross-tenant test for cancel_invitation

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

coderabbitai Bot commented Feb 10, 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 a token-based company invitation subsystem (create/accept/cancel/auto-accept), Invitation schema and notifier, owner-only Team LiveView and invitation-accept LiveView, membership management helpers, return_to sanitization, DB migrations, UI/router/auth changes, extensive tests, and docs checklist updates.

Changes

Cohort / File(s) Summary
Invitations Context & Schema
lib/ksef_hub/invitations.ex, lib/ksef_hub/invitations/invitation.ex
New Invitations context and Invitation schema: token generation and SHA‑256 hashing, expiry/status fields, create/accept/cancel/auto‑accept flows, validations, changeset rules, owner authorization and transactional acceptance.
Invitation Notifier
lib/ksef_hub/invitations/invitation_notifier.ex
Swoosh-based notifier delivering invitation emails (deliver_invitation/3) using configured sender and Mailer.
LiveViews & UI
lib/ksef_hub_web/live/team_live.ex, lib/ksef_hub_web/live/invitation_accept_live.ex, lib/ksef_hub_web/live/user_login_live.ex, lib/ksef_hub_web/live/user_registration_live.ex, lib/ksef_hub_web/components/layouts.ex
Team management LiveView (owner-only), Invitation-accept LiveView, propagate/preserve return_to in login/registration flows, and add "Team" sidebar link visible to owners.
Auth, Controllers & Helpers
lib/ksef_hub_web/user_auth.ex, lib/ksef_hub_web/controllers/user_session_controller.ex, lib/ksef_hub_web/controllers/auth_controller.ex, lib/ksef_hub_web/url_helpers.ex
log_in_user/3 now accepts params and honors sanitized return_to; session controller uses return_to; auth controller auto‑accepts pending invitations after OAuth sign-in; new UrlHelpers.sanitize_return_to/1 to prevent open redirects.
Membership Utilities
lib/ksef_hub/companies.ex
Added list_members/1 (preloads users, orders by role/name), delete_membership/1, and update_membership_role/2 with changeset validation.
Router & LiveAuth
lib/ksef_hub_web/router.ex, lib/ksef_hub_web/live/live_auth.ex
New live route for invitation acceptance and an owner-only live_session guarded by on_mount :require_owner to protect Team LiveView.
Migrations & Indexes
priv/repo/migrations/20260210300001_create_invitations.exs, priv/repo/migrations/20260210300002_add_email_index_to_invitations.exs
Create invitations table (binary_id) with token_hash, status, expires_at, company/user FKs, unique token_hash and partial unique index for pending (company_id,email); add email index.
Tests & Factory Additions
test/support/factory.ex, test/ksef_hub/companies_test.exs, test/ksef_hub/invitations_test.exs, test/ksef_hub/invitations/invitation_test.exs, test/ksef_hub/invitations/invitation_notifier_test.exs, test/ksef_hub_web/live/invitation_accept_live_test.exs, test/ksef_hub_web/live/invitation_auto_accept_test.exs, test/ksef_hub_web/live/team_live_test.exs, test/ksef_hub_web/user_auth_test.exs
Added invitation_factory and extensive tests covering schema validations, token behavior, create/accept/cancel/auto‑accept flows, LiveViews, notifier content, membership helpers, and return_to sanitization.
Docs / Plan
docs/implementation-plan.md
Updated implementation plan checklist statuses and minor wording; documentation/status changes only.

Sequence Diagram(s)

sequenceDiagram
    participant Owner as Owner
    participant TeamLV as Team LiveView
    participant Invitations as Invitations
    participant Notifier as InvitationNotifier
    participant Mailer as Mailer
    participant DB as Database

    Owner->>TeamLV: submit invite form
    TeamLV->>Invitations: create_invitation(company_id, owner_id, attrs)
    Invitations->>Invitations: authorize owner, check membership
    Invitations->>Invitations: generate raw token, hash token
    Invitations->>DB: insert invitation (token_hash, expires_at, pending)
    DB-->>Invitations: {:ok, invitation}
    Invitations-->>TeamLV: {:ok, invitation, raw_token}
    TeamLV->>Notifier: deliver_invitation(email, accept_url, ctx)
    Notifier->>Mailer: deliver(email)
    Mailer-->>Notifier: {:ok, email}
    Notifier-->>TeamLV: {:ok, email}
    TeamLV-->>Owner: show success
Loading
sequenceDiagram
    participant User as User
    participant AcceptLV as InvitationAcceptLive
    participant Invitations as Invitations
    participant Companies as Companies
    participant DB as Database

    User->>AcceptLV: GET /invitations/accept/:token
    alt authenticated
        AcceptLV->>Invitations: accept_invitation(token, user)
        Invitations->>Invitations: lookup by token_hash, check expiry, check membership
        Invitations->>DB: Ecto.Multi -> insert membership, update invitation status
        DB-->>Invitations: {:ok, {invitation, membership}}
        Invitations-->>AcceptLV: {:ok, ...}
        AcceptLV-->>User: redirect /dashboard (success)
    else not authenticated
        AcceptLV-->>User: redirect /users/log-in?return_to=/invitations/accept/:token
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Possibly related PRs

  • appunite/shugyo#17: Implements a similar token-based company invitation flow (schema, hashed tokens, accept/cancel, email delivery) — strong functional overlap.
  • Feat/memberships rbac #29: Related membership/domain changes used by the new Companies membership helpers and tests.
  • Feat/email password auth #31: Related auth/session/return_to handling that this change extends (UserAuth log_in_user/3 and session flows).

Poem

🐰 I hid a token in a carrot bright,
I hashed it snug and sent the light,
Owners invite across the glade,
New teammates hop — an eager brigade,
🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/company invitations' directly and accurately reflects the main objective of this pull request, which introduces a comprehensive company invitations feature including token-based invitation creation, acceptance, and management.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/company-invitations

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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub_web/live/invitation_accept_live.ex`:
- Around line 33-61: handle_accept/3 currently matches :ok, :not_found,
:expired, and :already_member but misses the {:error, %Ecto.Changeset{}} case
returned by Invitations.accept_invitation(token, user); add a new clause
matching {:error, %Ecto.Changeset{} = changeset} that returns {:ok, socket |>
assign(page_title: "Invitation Error") |> assign(error: :changeset, accepted:
false) |> assign(changeset: changeset)} (or include a put_flash with a generic
error message if preferred) so transaction-level validation failures are handled
without raising a FunctionClauseError.

In `@lib/ksef_hub_web/live/team_live.ex`:
- Around line 92-112: The remove_member handler lacks server-side role
validation and can allow deleting an owner; inside handle_event("remove_member")
after fetching membership with Companies.get_membership(member_user_id,
company.id) check membership.role (or membership.user_role) and if it equals
"owner" return {:noreply, put_flash(socket, :error, "Cannot remove company
owner.")} without calling Companies.delete_membership; otherwise proceed with
the existing delete flow that calls Companies.delete_membership and
load_team_data().

In `@lib/ksef_hub/invitations.ex`:
- Around line 54-60: The Invitation.changeset currently allows :status and
:expires_at to be mass-assigned; update the cast list in Invitation.changeset to
only [:email, :role] (remove :status and :expires_at) and modify the call site
that builds the %Invitation{} (the block that pipes into Invitation.changeset)
to set expires_at and status directly on the struct (alongside company_id,
invited_by_id, token_hash) before calling Invitation.changeset so those fields
are not cast from user input.
- Around line 140-155: The cancel_invitation/2 function can race with concurrent
accept_invitation because it reads the invitation with Repo.get and later
updates it; change it to perform an atomic conditional update using
Repo.update_all on Invitation filtered by id and status == "pending" after
authorization: call Companies.authorize(user_id, invitation.company_id,
["owner"]) as before (you may still Repo.get the invitation to check
existence/ownership), then run Repo.update_all(from(i in Invitation, where: i.id
== ^invitation_id and i.status == "pending"), set: [status: "cancelled"]) and
inspect the returned {count, _} to return {:ok, updated_invitation} when count
== 1 or {:error, :not_found} when count == 0 so an already-accepted invitation
is not overwritten.
- Around line 42-66: The create_invitation flow currently calls
check_not_already_member/2 and then Repo.insert which can race; refactor
create_invitation to build an Ecto.Multi: first use Multi.run (or Multi.run/3)
to perform Companies.authorize(user_id, company_id, ...) and then
check_not_already_member(company_id, email) inside the same transaction, next
use Multi.insert to insert the %Invitation{} via Invitation.changeset (using the
same token_hash/expires_at/status), and finally call Repo.transaction(multi) and
return {:ok, %{invitation: invitation, token: raw_token}} or {:error, reason}
based on the transaction result so the membership check and invitation insert
are atomic.
🧹 Nitpick comments (7)
lib/ksef_hub_web/controllers/auth_controller.ex (1)

38-43: Auto-accept integration looks correct, but consider adding observability.

The placement of accept_pending_invitations_for_email/1 after successful Google sign-in is correct. The function always returns {:ok, memberships} per the implementation in lib/ksef_hub/invitations.ex, so discarding the return value won't hide errors.

However, for observability, consider logging successful auto-accepts to help with debugging and auditing:

📊 Optional: Add logging for observability
         {:ok, user} ->
-          Invitations.accept_pending_invitations_for_email(user)
+          {:ok, memberships} = Invitations.accept_pending_invitations_for_email(user)
+
+          if memberships != [] do
+            require Logger
+            Logger.info("Auto-accepted #{length(memberships)} invitation(s) for user #{user.id}")
+          end

           conn
           |> put_flash(:info, "Welcome, #{user.name || user.email}!")
priv/repo/migrations/20260210300001_create_invitations.exs (1)

20-22: Consider adding an index on email for query performance.

The accept_pending_invitations_for_email/1 function queries invitations by email without a company_id filter, making a dedicated email index beneficial as the table grows. While a composite index on [:company_id, :email] exists, it cannot efficiently support email-only filtering.

🔧 Proposed index addition
     create index(:invitations, [:company_id])
     create index(:invitations, [:invited_by_id])
     create index(:invitations, [:token_hash], unique: true)
+    create index(:invitations, [:email])
lib/ksef_hub_web/live/user_registration_live.ex (1)

32-33: Consider logging failures from invitation acceptance.

The call to accept_pending_invitations_for_email/1 ignores its return value. While this is correct behavior (registration should succeed regardless), any failures during invitation acceptance will be silent. Consider capturing the result for logging, similar to how confirmation email failures are logged below (lines 41-43).

💡 Optional enhancement for observability
-        Invitations.accept_pending_invitations_for_email(user)
+        case Invitations.accept_pending_invitations_for_email(user) do
+          {:ok, memberships} when memberships != [] ->
+            require Logger
+            Logger.info("Auto-accepted #{length(memberships)} invitation(s) for user #{user.id}")
+
+          _ ->
+            :ok
+        end
test/ksef_hub_web/live/invitation_accept_live_test.exs (1)

43-71: Consider adding a test for already-accepted invitation.

The tests cover expired and invalid tokens well. Per the AI summary, there should also be an "already-member" case that redirects with a notice. Consider adding a test for attempting to accept an invitation when the user is already a member of the company.

📝 Suggested test addition
test "shows notice when user is already a member", %{conn: conn, company: company, owner: owner, user: user} do
  # User is already a member from accepting the first invitation in setup
  # Create another invitation for the same user
  {:ok, %{token: token}} =
    Invitations.create_invitation(owner.id, company.id, %{
      email: "another-email@example.com",  # Different email but same user
      role: "invoice_reviewer"
    })

  # This test would need adjustment based on actual already-member handling
  # Verify appropriate redirect or message
end
lib/ksef_hub_web/live/team_live.ex (2)

116-124: Consider using LiveView streams for the collections.

The coding guidelines specify to always use LiveView streams for collections to avoid memory ballooning. While team lists are typically small, using streams would align with the project conventions and prevent issues if teams grow larger.

♻️ Proposed refactor using streams
  `@spec` load_team_data(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
  defp load_team_data(socket) do
    company = socket.assigns.current_company
    members = Companies.list_members(company.id)
    pending_invitations = Invitations.list_pending_invitations(company.id)

    socket
-   |> assign(members: members, pending_invitations: pending_invitations)
+   |> stream(:members, members, reset: true)
+   |> stream(:pending_invitations, pending_invitations, reset: true)
  end

Then update mount to initialize streams and update the template to use @streams.members and @streams.pending_invitations.

As per coding guidelines: "Always use LiveView streams for collections to avoid memory ballooning and runtime termination."


126-145: Silently swallowing email delivery errors may hide issues.

The send_invitation_email/3 function logs a warning on failure but returns :ok regardless. While this prevents user-facing errors for email delivery issues, consider whether the calling code should be aware of the failure (e.g., to show a different flash message like "Invitation created but email delivery failed").

lib/ksef_hub_web/live/user_login_live.ex (1)

37-49: Consider extracting the duplicated sanitize_return_to/1 helper to a shared module.

This function appears identically in both lib/ksef_hub_web/user_auth.ex (lines 65–76) and lib/ksef_hub_web/live/user_login_live.ex (lines 38–49). Extract it to a shared module (e.g., KsefHubWeb.Security or KsefHubWeb.UrlSanitizer) to maintain a single source of truth for URL sanitization logic and reduce maintenance burden.

Comment thread lib/ksef_hub_web/live/invitation_accept_live.ex
Comment thread lib/ksef_hub_web/live/team_live.ex
Comment thread lib/ksef_hub/invitations.ex
Comment thread lib/ksef_hub/invitations.ex Outdated
Comment thread lib/ksef_hub/invitations.ex
…ements

- Tighten Invitation changeset: remove :status/:expires_at from cast list,
  set them on struct at call sites to prevent mass-assignment
- Wrap create_invitation in Ecto.Multi for atomic authorize + member check + insert
- Make cancel_invitation atomic with Repo.update_all to prevent race with accept
- Add {:error, %Ecto.Changeset{}} handler to InvitationAcceptLive
- Add server-side owner removal guard in TeamLive remove_member handler
- Show different flash when invitation email delivery fails
- Use LiveView streams for members and pending_invitations collections
- Extract duplicated sanitize_return_to/1 to shared KsefHubWeb.UrlHelpers
- Add observability logging for auto-accept on sign-up and Google login
- Add email index on invitations table for accept_pending_invitations_for_email
- Add test: already-member invitation accept, owner removal guard, status/expires_at
  not castable

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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub/invitations.ex`:
- Around line 124-143: The current pre-check in do_accept_invitation calling
Companies.get_membership(user.id, invitation.company_id) causes a race; move
that membership existence check into the Ecto.Multi using Multi.run/3 (e.g., add
a :check_membership run that queries Companies.get_membership/2 and returns
{:ok, :exists} or {:error, :already_member}) and then perform the
Multi.insert(:membership, ...) after the run so the check and insert are atomic;
remove the outer if-else pre-check and ensure the Multi.run returns {:error,
:already_member} when a membership exists so concurrent requests yield a
consistent {:error, :already_member} response instead of a changeset DB error.
🧹 Nitpick comments (3)
lib/ksef_hub_web/live/user_registration_live.ex (1)

32-37: Move require Logger to module level.

require Logger appears inline at both line 35 and line 47. While this works (it's a compile-time directive), the idiomatic approach is to place require Logger once at the module level alongside the aliases.

♻️ Suggested refactor

Add at module level (after line 10):

alias KsefHub.Invitations

require Logger

Then simplify the inline code:

        {:ok, memberships} = Invitations.accept_pending_invitations_for_email(user)

        if memberships != [] do
-         require Logger
          Logger.info("Auto-accepted #{length(memberships)} invitation(s) for user #{user.id}")
        end

And similarly at line 47:

          {:error, _reason} ->
-           require Logger
            Logger.error("Failed to deliver confirmation email")
lib/ksef_hub_web/live/team_live.ex (1)

19-35: Consider using a plug or hook for owner authorization.

The owner check in mount/3 works correctly, but duplicating authorization logic in LiveViews can become error-prone as the codebase grows. Consider extracting this to a reusable on_mount hook for owner-only pages.

lib/ksef_hub/invitations.ex (1)

216-236: Silent failure in auto-accept may hide issues.

The accept_pending_invitations_for_email/1 function silently ignores errors from do_accept_invitation/2 (line 231). While this prevents one failed invitation from blocking others, consider logging errors for observability so issues like database failures or unexpected states are visible.

♻️ Proposed logging for failed auto-accepts
+  require Logger
+
   `@spec` accept_pending_invitations_for_email(User.t()) :: {:ok, [Membership.t()]}
   def accept_pending_invitations_for_email(%User{} = user) do
     email = normalize_email(user.email)
     now = DateTime.utc_now()

     invitations =
       Invitation
       |> where([i], i.email == ^email)
       |> where([i], i.status == "pending")
       |> where([i], i.expires_at > ^now)
       |> Repo.all()

     memberships =
       Enum.reduce(invitations, [], fn invitation, acc ->
         case do_accept_invitation(invitation, user) do
           {:ok, %{membership: membership}} -> [membership | acc]
-          {:error, _} -> acc
+          {:error, reason} ->
+            Logger.warning("Failed to auto-accept invitation #{invitation.id}: #{inspect(reason)}")
+            acc
         end
       end)

     {:ok, Enum.reverse(memberships)}
   end

Comment thread lib/ksef_hub/invitations.ex
Move membership existence check into Ecto.Multi in do_accept_invitation
to prevent race condition. Extract owner authorization to reusable
:require_owner on_mount hook. Move require Logger to module level in
UserRegistrationLive. Add warning logs for auto-accept failures.

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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub/invitations.ex`:
- Around line 126-147: do_accept_invitation/2 currently uses Multi.update with a
stale invitation struct which can re-accept a cancelled invite; replace the
Multi.update(:invitation, ...) step with a conditional update using
Repo.update_all (or a Multi.run that calls Repo.update_all) to set status:
"accepted" only when status == "pending" (mirror do_atomic_cancel/1 approach),
return {:error, :not_found} when update_all returns {0, _} (no rows updated),
and update the `@spec` for do_accept_invitation/2 to include | {:error,
:not_found}; keep the rest of the Multi (check_membership, insert membership)
and ensure you map the update_all result into the {:ok, invitation} shape for
the success clause.

Comment thread lib/ksef_hub/invitations.ex
Replace Multi.update (stale struct) with atomic Repo.update_all that
only sets status to "accepted" when status is still "pending", preventing
re-acceptance of cancelled invitations. Mirrors do_atomic_cancel pattern.

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

🤖 Fix all issues with AI agents
In `@lib/ksef_hub/invitations.ex`:
- Around line 122-166: In do_accept_invitation update the transactional guard
and error handling: extend the Repo.update_all WHERE to include expires_at >
^now so the row is only claimed if still valid, and if the update count is 0
then fetch the invitation (Repo.get/1) and return {:error, :expired} when
invitation.expires_at <= now versus {:error, :not_found} otherwise; also update
the `@spec` for do_accept_invitation to include {:error, :expired} as a possible
return. Ensure you reference the function do_accept_invitation, the Multi.run
block labeled :invitation that calls Repo.update_all, and the final
Repo.transaction result clause to add a branch for {:error, :invitation,
:expired, _changes} mapping to {:error, :expired}.

Comment thread lib/ksef_hub/invitations.ex
emilwojtaszek and others added 2 commits February 11, 2026 01:21
…_found

Extend Repo.update_all WHERE clause in do_accept_invitation to include
expires_at > now so expired invitations are not claimed. When update
count is 0, fetch the invitation to distinguish {:error, :expired} from
{:error, :not_found}.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract atomic update and failure classification into do_atomic_accept/1
and classify_accept_failure/2 to resolve nesting depth and cyclomatic
complexity warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 4f48303 into main Feb 11, 2026
3 checks passed
This was referenced Feb 11, 2026
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