Skip to main content

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:

FieldPurpose
emailWho is being invited. Used to send the invitation email and to verify the invitee's identity on acceptance.
roleThe intended role: hq_manager, area_manager, or outlet_manager
outlet_idsOptional. For area_manager and outlet_manager, specifies which outlets to assign on acceptance.
first_name, last_nameUsed to address the invitee in the email. Also used to pre-fill the user profile if they are new to Jod.
titleThe 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:

  1. System generates a random token and stores its digest (hash)
  2. The invitation email contains a link with the raw token
  3. When the invitee clicks the link, the system verifies the token against the digest
  4. 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

ContextDetails
EntityOrg::Invite (independent entity)
LayerOrganisation Structure — Onboarding
Upstream dependenciesOrg::Company (the company being joined), Org::Membership (the inviter must be an hq_manager)
Downstream dependentsOrg::Membership (created on acceptance), Org::OutletAssignment (created on acceptance if outlets specified)

State Machine

FromToTriggerNotes
(new)pendingUC-1: Invite sentEmail dispatched. Token generated. Expiry job scheduled.
pendingclickedInvitee clicks the link in the emailToken verified. clicked_at recorded. Invitee sees acceptance page.
clickedacceptedUC-2: Invitee completes acceptanceMembership + OutletAssignments created. accepted_at recorded.
pendingacceptedUC-2: Existing user accepts directlyIf user already has a Jod account, they can accept without clicking the email first.
pendingrevokedUC-4: Inviter cancels the inviterevoked_at recorded. Email link becomes invalid.
clickedrevokedUC-4: Inviter cancels after invitee clickedSame as above.
pendingexpiredUC-5: Expiry time reachedBackground job transitions the invite. Email link becomes invalid.
clickedexpiredUC-5: Expiry time reached after clickInvitee clicked but did not complete acceptance in time.

Use Cases

IDUse CaseTriggerActor
UC-1Send an invitehq_manager wants to add a team memberhq_manager or Admin
UC-2Accept an inviteInvitee clicks the link and completes sign-upInvitee
UC-3Resend an inviteOriginal invite expired or invitee lost the emailhq_manager or Admin
UC-4Revoke an inviteInviter cancels the invite before acceptancehq_manager or Admin
UC-5Expire an inviteConfigured expiry time reachedSystem
UC-6View pending invites for a companyhq_manager reviews outstanding invitationshq_manager

UC-1: Send an invite

FieldDetails
Actorhq_manager of the company, or Identities::Admin
Triggerhq_manager wants to add a team member to the company

Preconditions:

  • Actor has hq_manager role in the company (or is admin)
  • No active (pending/clicked) invite exists for this email + company combination

System Behavior:

  1. Actor enters: email, first name, last name, title, role
  2. For area_manager or outlet_manager roles: actor optionally selects outlets to assign on acceptance
  3. System generates a secure token and stores its digest
  4. System creates the Org::Invite in pending status with expires_at set
  5. System schedules a background job to expire the invite (stores expire_jid)
  6. 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 pending status
  • Invitation email sent to the invitee
  • Expiry background job scheduled

UC-2: Accept an invite

FieldDetails
ActorThe invitee (person who received the email)
TriggerInvitee clicks the link and completes acceptance

Preconditions:

  • Invite exists in pending or clicked status
  • Invite has not expired (expires_at > now)
  • Token is valid

System Behavior:

  1. System verifies the token against token_digest
  2. If invitee has no Identities::User: system creates one with the provided email, first name, last name
  3. If invitee already has an Identities::User: system links the existing account
  4. System creates Org::Membership with the role and title from the invite
  5. If outlet IDs were specified on the invite: system creates Org::OutletAssignment records
  6. If this is the user's first membership: is_default is set to true
  7. System transitions the invite to accepted and records accepted_at
  8. 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::Membership exists with the specified role, title, and company
  • Org::OutletAssignment records exist (if outlets were specified)
  • Invitee can access the company immediately

UC-3: Resend an invite

FieldDetails
Actorhq_manager of the company, or Identities::Admin
TriggerOriginal invite expired or invitee lost the email

Preconditions:

  • Invite exists in pending, clicked, or expired status

System Behavior:

  1. System generates a new token and updates token_digest
  2. System resets expires_at to a new expiry time
  3. System cancels the old expiry background job (using expire_jid)
  4. System schedules a new expiry background job (updates expire_jid)
  5. System sends a new invitation email
  6. If the invite was expired, system transitions it back to pending

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 accepted or revoked invite — 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

FieldDetails
Actorhq_manager of the company, or Identities::Admin
TriggerInviter cancels the invite before it is accepted

Preconditions:

  • Invite exists in pending or clicked status

System Behavior:

  1. System transitions the invite to revoked
  2. System records revoked_at
  3. System cancels the expiry background job

Business Rules:

  • Cannot revoke an invite that is already accepted, revoked, or expired
  • 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

FieldDetails
ActorSystem (background job)
TriggerConfigured expiry time reached

Preconditions:

  • Invite exists in pending or clicked status
  • expires_at <= now

System Behavior:

  1. Background job transitions the invite to expired

Business Rules:

  • Only pending and clicked invites 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

FieldDetails
Actorhq_manager of the company
Triggerhq_manager reviews outstanding invitations

Preconditions:

  • Actor has hq_manager role in the company

System Behavior:

  1. System displays all invites for the company: email, name, role, status, sent date, expiry date
  2. Filterable by status (pending, clicked, accepted, expired, revoked)

Postconditions:

  • Read-only operation — no data changes

Invariants

  1. An invite belongs to exactly one Org::Company
  2. Only one active invite (status pending or clicked) can exist per email per company at any time
  3. The invite must specify a valid role (hq_manager, area_manager, outlet_manager)
  4. If role is outlet_manager, exactly 1 outlet must be specified
  5. Specified outlets must belong to the same company as the invite
  6. An accepted invite cannot be revoked, resent, or expired
  7. Acceptance creates exactly one Org::Membership — if a membership already exists for that user + company, acceptance fails
  8. The token digest is regenerated on every resend — old email links become invalid
  9. Invites are never hard-deleted — they serve as an audit trail of who was invited, when, and by whom

Model Interactions

Related ModelRelationshipInteraction
Org::CompanyInvite belongs_to CompanyThe company the invitee will join. All outlet references must belong to this company.
Org::MembershipInvite created_by Membership (inviter)The hq_manager who sent the invite. Stored as created_by for audit.
Org::MembershipAcceptance creates Membership (invitee)On acceptance, system creates a new Membership with the invite's role, title, and company.
Org::OutletAssignmentAcceptance creates OutletAssignmentsIf the invite specified outlets, system creates OutletAssignment records linked to the new Membership.
Org::OutletInvite references Outlets (optional)Outlets to assign on acceptance. Validated at invite creation time (must belong to same company, must be active).
Identities::UserAcceptance creates or links UserIf the invitee is new to Jod, an Identities::User is created. If they already have an account, it is linked.

Schema Gaps

GapImpactSuggested Resolution
Table is named org_user_invitationsDoes not match the new Org::Invite model nameResolved in DBML. Table defined as org_invites.
No role columnCannot specify the intended role at invitation timeResolved in DBML. role string [not null] column defined on org_invites.
No outlet referenceCannot specify outlet assignments at invitation timeResolved in DBML. org_invite_outlets join table defined with invite_id and outlet_id foreign keys.
created_by references org_user_profilesShould reference org_memberships after renameResolved 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 simultaneouslyResolved in DBML. Composite unique index on (email, org_company_id) defined.