Org::Invite
Purpose
An Org::Invite represents an invitation for someone to join an Org::Company as a member. It carries the intended role and, optionally, outlet assignments — so when the invitee accepts, the system knows exactly what Org::Membership and Org::OutletAssignment records to create.
Invitations are sent by email. The invitee does not need an existing Identities::User account — one is created during acceptance if needed. If the invitee already has an account (e.g., they use Jod at another company), accepting the invite adds a second Org::Membership, giving them multi-company access.
Org::Invite is renamed from Org::UserInvitation (table: org_user_invitations). The rename aligns with the broader Org domain naming: Org::Membership (not UserProfile), Org::Invite (not UserInvitation).
Key Concepts
What the Invite Carries
An invite captures everything needed to create a membership on acceptance:
| Field | Purpose |
|---|---|
email | Who is being invited. Used to send the invitation email and to verify the invitee's identity on acceptance. |
role | The intended role: hq_manager, area_manager, or outlet_manager |
outlet_ids | Optional. For area_manager and outlet_manager, specifies which outlets to assign on acceptance. |
first_name, last_name | Used to address the invitee in the email. Also used to pre-fill the user profile if they are new to Jod. |
title | The invitee's job title at this company. Carried over to the Org::Membership on acceptance. |
Token-Based Acceptance
Each invite has a secure token (token_digest) used to verify acceptance. The flow:
- System generates a random token and stores its digest (hash)
- The invitation email contains a link with the raw token
- When the invitee clicks the link, the system verifies the token against the digest
- On verification, the invitee can create their account (or link their existing one) and accept
Expiry
Invites expire after a configured period (stored in expires_at). An expired invite cannot be accepted. The inviter can resend the invitation, which generates a new token and resets the expiry.
A background job (tracked by expire_jid) handles automatic expiry. When the invite is resent, the previous expiry job is cancelled and a new one is scheduled.
Model Context
| Context | Details |
|---|---|
| Entity | Org::Invite (independent entity) |
| Layer | Organisation Structure — Onboarding |
| Upstream dependencies | Org::Company (the company being joined), Org::Membership (the inviter must be an hq_manager) |
| Downstream dependents | Org::Membership (created on acceptance), Org::OutletAssignment (created on acceptance if outlets specified) |
State Machine
| From | To | Trigger | Notes |
|---|---|---|---|
| (new) | pending | UC-1: Invite sent | Email dispatched. Token generated. Expiry job scheduled. |
pending | clicked | Invitee clicks the link in the email | Token verified. clicked_at recorded. Invitee sees acceptance page. |
clicked | accepted | UC-2: Invitee completes acceptance | Membership + OutletAssignments created. accepted_at recorded. |
pending | accepted | UC-2: Existing user accepts directly | If user already has a Jod account, they can accept without clicking the email first. |
pending | revoked | UC-4: Inviter cancels the invite | revoked_at recorded. Email link becomes invalid. |
clicked | revoked | UC-4: Inviter cancels after invitee clicked | Same as above. |
pending | expired | UC-5: Expiry time reached | Background job transitions the invite. Email link becomes invalid. |
clicked | expired | UC-5: Expiry time reached after click | Invitee clicked but did not complete acceptance in time. |
Use Cases
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Send an invite | hq_manager wants to add a team member | hq_manager or Admin |
| UC-2 | Accept an invite | Invitee clicks the link and completes sign-up | Invitee |
| UC-3 | Resend an invite | Original invite expired or invitee lost the email | hq_manager or Admin |
| UC-4 | Revoke an invite | Inviter cancels the invite before acceptance | hq_manager or Admin |
| UC-5 | Expire an invite | Configured expiry time reached | System |
| UC-6 | View pending invites for a company | hq_manager reviews outstanding invitations | hq_manager |
UC-1: Send an invite
| Field | Details |
|---|---|
| Actor | hq_manager of the company, or Identities::Admin |
| Trigger | hq_manager wants to add a team member to the company |
Preconditions:
- Actor has
hq_managerrole in the company (or is admin) - No active (pending/clicked) invite exists for this email + company combination
System Behavior:
- Actor enters: email, first name, last name, title, role
- For
area_manageroroutlet_managerroles: actor optionally selects outlets to assign on acceptance - System generates a secure token and stores its digest
- System creates the
Org::Inviteinpendingstatus withexpires_atset - System schedules a background job to expire the invite (stores
expire_jid) - System sends the invitation email with a link containing the raw token
Business Rules:
- Only
hq_manager(or admin) can send invites - One active invite per email per company — if a pending/clicked invite already exists for this email at this company, the actor must revoke it first or resend (UC-3)
- The invite carries the intended role and outlet assignments so that acceptance is a one-step process
- If role is
outlet_manager, exactly 1 outlet must be specified - If role is
area_manager, at least 1 outlet should be specified (can be zero if outlets will be assigned later) - If role is
hq_manager, no outlets should be specified
Postconditions:
- Invite exists in
pendingstatus - Invitation email sent to the invitee
- Expiry background job scheduled
UC-2: Accept an invite
| Field | Details |
|---|---|
| Actor | The invitee (person who received the email) |
| Trigger | Invitee clicks the link and completes acceptance |
Preconditions:
- Invite exists in
pendingorclickedstatus - Invite has not expired (
expires_at > now) - Token is valid
System Behavior:
- System verifies the token against
token_digest - If invitee has no
Identities::User: system creates one with the provided email, first name, last name - If invitee already has an
Identities::User: system links the existing account - System creates
Org::Membershipwith the role and title from the invite - If outlet IDs were specified on the invite: system creates
Org::OutletAssignmentrecords - If this is the user's first membership:
is_defaultis set totrue - System transitions the invite to
acceptedand recordsaccepted_at - System cancels the expiry background job
Business Rules:
- A user cannot accept an invite if they already have an active membership in that company
- The role and outlet assignments come from the invite — the invitee does not choose their own role
- If the invitee already has memberships at other companies, this creates multi-company access
- Acceptance is atomic: Membership + OutletAssignments + invite status update happen in one transaction
Postconditions:
- Invite status is
accepted Org::Membershipexists with the specified role, title, and companyOrg::OutletAssignmentrecords exist (if outlets were specified)- Invitee can access the company immediately
UC-3: Resend an invite
| Field | Details |
|---|---|
| Actor | hq_manager of the company, or Identities::Admin |
| Trigger | Original invite expired or invitee lost the email |
Preconditions:
- Invite exists in
pending,clicked, orexpiredstatus
System Behavior:
- System generates a new token and updates
token_digest - System resets
expires_atto a new expiry time - System cancels the old expiry background job (using
expire_jid) - System schedules a new expiry background job (updates
expire_jid) - System sends a new invitation email
- If the invite was
expired, system transitions it back topending
Business Rules:
- Resending does not create a new invite record — it refreshes the existing one
- The role and outlet assignments remain unchanged (if the actor wants to change them, they should revoke and create a new invite)
- Cannot resend an
acceptedorrevokedinvite — create a new one instead
Postconditions:
- Invite has a new token and expiry
- New invitation email sent
- Old email link is now invalid (old token no longer matches)
UC-4: Revoke an invite
| Field | Details |
|---|---|
| Actor | hq_manager of the company, or Identities::Admin |
| Trigger | Inviter cancels the invite before it is accepted |
Preconditions:
- Invite exists in
pendingorclickedstatus
System Behavior:
- System transitions the invite to
revoked - System records
revoked_at - System cancels the expiry background job
Business Rules:
- Cannot revoke an invite that is already
accepted,revoked, orexpired - Revoking makes the email link invalid
- The inviter can send a new invite to the same email after revoking
Postconditions:
- Invite status is
revoked - Email link no longer works
- A new invite can be sent to this email for this company
UC-5: Expire an invite
| Field | Details |
|---|---|
| Actor | System (background job) |
| Trigger | Configured expiry time reached |
Preconditions:
- Invite exists in
pendingorclickedstatus expires_at <= now
System Behavior:
- Background job transitions the invite to
expired
Business Rules:
- Only
pendingandclickedinvites can expire - Once expired, the email link no longer works
- The inviter can resend (UC-3) to reactivate the invite
Postconditions:
- Invite status is
expired - Email link no longer works
UC-6: View pending invites for a company
| Field | Details |
|---|---|
| Actor | hq_manager of the company |
| Trigger | hq_manager reviews outstanding invitations |
Preconditions:
- Actor has
hq_managerrole in the company
System Behavior:
- System displays all invites for the company: email, name, role, status, sent date, expiry date
- Filterable by status (pending, clicked, accepted, expired, revoked)
Postconditions:
- Read-only operation — no data changes
Invariants
- An invite belongs to exactly one
Org::Company - Only one active invite (status
pendingorclicked) can exist per email per company at any time - The invite must specify a valid role (
hq_manager,area_manager,outlet_manager) - If role is
outlet_manager, exactly 1 outlet must be specified - Specified outlets must belong to the same company as the invite
- An accepted invite cannot be revoked, resent, or expired
- Acceptance creates exactly one
Org::Membership— if a membership already exists for that user + company, acceptance fails - The token digest is regenerated on every resend — old email links become invalid
- Invites are never hard-deleted — they serve as an audit trail of who was invited, when, and by whom
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Org::Company | Invite belongs_to Company | The company the invitee will join. All outlet references must belong to this company. |
Org::Membership | Invite created_by Membership (inviter) | The hq_manager who sent the invite. Stored as created_by for audit. |
Org::Membership | Acceptance creates Membership (invitee) | On acceptance, system creates a new Membership with the invite's role, title, and company. |
Org::OutletAssignment | Acceptance creates OutletAssignments | If the invite specified outlets, system creates OutletAssignment records linked to the new Membership. |
Org::Outlet | Invite references Outlets (optional) | Outlets to assign on acceptance. Validated at invite creation time (must belong to same company, must be active). |
Identities::User | Acceptance creates or links User | If the invitee is new to Jod, an Identities::User is created. If they already have an account, it is linked. |
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
Table is named org_user_invitations | Does not match the new Org::Invite model name | Resolved in DBML. Table defined as org_invites. |
No role column | Cannot specify the intended role at invitation time | Resolved in DBML. role string [not null] column defined on org_invites. |
| No outlet reference | Cannot specify outlet assignments at invitation time | Resolved in DBML. org_invite_outlets join table defined with invite_id and outlet_id foreign keys. |
created_by references org_user_profiles | Should reference org_memberships after rename | Resolved in DBML. created_by_id references org_memberships in DBML. |
email has a unique index (globally) | Prevents the same email from being invited to two different companies simultaneously | Resolved in DBML. Composite unique index on (email, org_company_id) defined. |