Invitation Lifecycle and State Management#
Overview#
The Invitation model manages household (family) membership invitations. An invitation has three logical states determined by two columns — accepted_at and expires_at — with no explicit state column:
| State | Condition |
|---|---|
| Pending | accepted_at IS NULL AND expires_at > now |
| Accepted | accepted_at IS NOT NULL |
| Expired | accepted_at IS NULL AND expires_at <= now |
These map to the model scopes Invitation.pending and Invitation.accepted. There is no expired scope — expired records are kept for audit trail but identified by exclusion. Invitations expire after 3 days (set_expiration before_create callback).
Entry points:
- Model:
app/models/invitation.rb - Controller:
app/controllers/invitations_controller.rb
Acceptance Flow#
Invitation#accept_for(user) is the canonical acceptance path. It guards against:
- Invitation no longer pending (
pending?check) - Email mismatch between invitation and accepting user
- Orphaning owned accounts (user has accounts in their current family that wouldn't transfer)
On success, a transaction atomically moves the user into the new family, sets accepted_at, and calls family.auto_share_existing_accounts_with(user).
The controller fast-paths existing users: if a matching User already exists at create time, it calls accept_for immediately instead of sending an invitation email.
Uniqueness Constraints#
Two complementary layers enforce uniqueness:
1. Database — partial unique index#
index_invitations_on_email_and_family_id_pending is a partial unique index on (email, family_id) WHERE accepted_at IS NULL. This replaced a full composite unique index (PR #1185) so that a family can re-invite the same email after a prior acceptance (accepted rows are excluded from the index).
2. Application — two model validations#
no_duplicate_pending_invitation_in_family— blocks a second pending invitation to the same email within the same family. Runs on create and update.no_other_pending_invitation— blocks creating a pending invitation when the same email already has a pending invitation in any other family. Enforces the rule that a user can only belong to one family. Added in PR #1173.
Both validations are encryption-aware, using deterministic encrypted queries when encryption_ready? and LOWER(email) = ? otherwise.
The Expiry/Index Mismatch and Lazy Deletion#
A critical divergence exists between the application and database definitions of "pending":
- App
pendingscope:accepted_at IS NULL AND expires_at > now - Partial index predicate:
accepted_at IS NULL(no time clause —now()isSTABLE, notIMMUTABLE, so Postgres can't use it in a partial index predicate)
This means an expired, never-accepted invitation is invisible to validations (excluded by expires_at > now) but still occupies the unique index slot. Before PR #2543, re-inviting an expired email would pass validation but then crash with ActiveRecord::RecordNotUnique on the INSERT.
Fix — before_create :remove_expired_duplicates_in_family: runs after validations pass but before the INSERT, and delete_alls any expired unaccepted rows for the same (email, family_id). This is intentional lazy deletion — expired records are retained for audit purposes until a re-invite triggers cleanup. A still-pending duplicate can never be removed by this callback because the no_duplicate_pending_invitation_in_family validation would have already blocked the request first.
Race Condition Handling#
Even with the before_create cleanup and model validations, a concurrent double-submit can slip between the validation check and the INSERT. The controller isolates this with save_invitation, a thin wrapper that rescues ActiveRecord::RecordNotUnique from the save call only, returning false and showing the standard failure flash. This scoping is deliberate — it prevents the rescue from silently swallowing genuine RecordNotUnique errors from later writes in #create (e.g., accept_for). Added in PR #2543.
Admin Management#
Pending invitations are visible and deletable via /admin/users. The admin panel shows pending invitations grouped by family with per-invitation delete and a bulk "Delete All" option (alt-click). Admin deletion routes live in Admin::InvitationsController (destroy / destroy_all).
Key Files#
| File | Purpose |
|---|---|
app/models/invitation.rb | State logic, validations, acceptance, lazy deletion |
app/controllers/invitations_controller.rb | Create/accept/destroy, race condition rescue |
app/controllers/admin/invitations_controller.rb | Admin destroy / destroy_all |
db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb | Introduces partial unique index |