> ## Documentation Index
> Fetch the complete documentation index at: https://doc.askmydocs.padosoft.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Invitations & Referrals

> Invite-by-code onboarding, referrals, rewards, waitlist and fail-open anti-abuse — powered by the standalone padosoft/laravel-invitations engine and wired tri-surface (PHP + HTTP + MCP) into AskMyDocs over vendor-neutral seams.

## Motivation

A closed-beta or seat-controlled product needs more than "send a magic link".
It needs **campaigns** (a launch wave, an investor list, a conference QR), codes
that are **multi-use or single-use or vanity**, **referrals** that reward the
referrer when the invitee converts, a **waitlist** with queue-jumping, and an
**anti-abuse** layer so a leaked code can't be farmed into thousands of free
seats — all of it **multi-tenant**, because two customers may legitimately mint
the same human-readable code.

AskMyDocs gets this from the standalone
[`padosoft/laravel-invitations`](https://github.com/padosoft/laravel-invitations)
engine rather than re-implementing it inline. The package is **vendor-neutral**:
it types against interfaces (`TenantResolver`, `Provisioner`, `InvitedAccount`),
not against `App\Models\User` or AskMyDocs's tenant context. The host's job is to
*bind those seams* — and to decide what an accepted invite actually grants.

That decision is the interesting part. In AskMyDocs an invite carries a
per-tenant **grant**: a Spatie role **and** a set of KB **project memberships**.
So a single code can onboard a user into the right tenant, with the right role,
scoped to the right projects, in one redemption.

## Theory — where the coupling lives

The engine is \~80% domain-agnostic. The only host-specific surface is *what
happens after a redemption commits* — applying the grant. The package isolates
that behind two contracts and a tag:

* **`TenantResolver`** — `current(): string`. Every invite table carries
  `tenant_id`; every query is scoped through this resolver. A plain app gets the
  package's single-tenant default; a multi-tenant host binds its own.
* **`InvitedAccount`** — `getInviteEmail(): ?string` + `getInviteGuardName():
  string`. The engine reads only these two account attributes (email for
  abuse-correlation hashing, guard for role provisioning) so it never couples to
  a concrete user class.
* **`Provisioner`** (tagged `invitations.provisioners`) — `provision(Model
  $account, TenantGrant $grant): void`. The package ships
  `SpatiePermissionProvisioner` (grants the role); a host adds more under the
  same tag. Two invariants the contract mandates: **GRANT-never-REVOKE** (only
  ever raise access) and **best-effort** (a fault is swallowed + logged, never
  thrown — the redemption is *already committed* when provisioning runs).

AskMyDocs satisfies all three without touching the engine. That is the whole
integration: three bindings and a config.

## Design

```mermaid theme={null}
flowchart TD
    Redeem["POST /api/invitations/redeem<br/>(authenticated user)"] --> RS["RedemptionService<br/>(atomic claim)"]
    RS -->|"UPDATE … WHERE current_uses < max_uses<br/>+ UNIQUE(code_id, redeemer_id)"| DB[("invite_codes / invite_redemptions<br/>(tenant_id on every row)")]
    RS --> APS["AccountProvisioningService<br/>(fan-out per tenant grant)"]
    APS --> P1["SpatiePermissionProvisioner<br/>(package — grants the role)"]
    APS --> P2["ProjectMembershipProvisioner<br/>(host — project_memberships, GRANT-never-REVOKE)"]
    TR["App\\Support\\TenantContext"] -.->|"bound to TenantResolver"| RS
    TR -.-> P2
    Admin["GET/POST /api/admin/invitations/*<br/>(can:manageInvitations)"] --> Core["CampaignService / CodeGenerator / MetricsService"]
    MCP["Invite{ValidateCode,GenerateCodes,Metrics}Tool"] --> Core
    CLI["invite:* console"] --> Core
    Core --> DB
```

Three things the host wires, all in `App\Providers\AppServiceProvider::boot()`
so they win over the package's `packageRegistered()` defaults (the same
boot-vs-register ordering AskMyDocs uses for its MCP and evidence-risk-review
adapters):

1. **`TenantResolver` → `App\Support\TenantContext`.** An anonymous class adapts
   the host context's `current()` to the package contract. The package binds its
   single-tenant default with `bindIf()`; an explicit `bind()` in `boot()`
   overrides it definitively. Result: every invite read/write is scoped to the
   tenant the request resolved (R30).
2. **`App\Invitations\ProjectMembershipProvisioner` → the
   `invitations.provisioners` tag.** Appended to the tag alongside the package's
   `SpatiePermissionProvisioner`. The package's contextual
   `giveTagged('invitations.provisioners')` for `AccountProvisioningService`
   resolves the tag lazily at redemption time, so both provisioners run: the
   Spatie one raises the global role, ours raises per-project access.
3. **`manageInvitations` gate + `config/invitations.php`.** The package routes
   carry no internal authorization — admin gating is entirely the host
   `admin_middleware` config (R32). AskMyDocs sets it to its standard admin stack
   plus `can:manageInvitations`.

## Data model

The package owns 9 tenant-aware tables (created by its own migrations, R30/R31
enforced in its CI):

| Table                     | Holds                                                                                                |
| ------------------------- | ---------------------------------------------------------------------------------------------------- |
| `invite_campaigns`        | a named wave with a default grant                                                                    |
| `invite_codes`            | a code (multi-use / single-use / vanity) with `current_uses` / `max_uses` and its own grant override |
| `invitations`             | a direct, addressed invitation (high-entropy link token)                                             |
| `invite_redemptions`      | the claim ledger — `UNIQUE(code_id, redeemer_id)` is the idempotency + concurrency anchor            |
| `invite_referrals`        | referrer → invitee links for the K-factor loop                                                       |
| `invite_rewards`          | the reward ledger (states + reversal)                                                                |
| `invite_waitlist`         | waitlist entries with queue-jump on referral                                                         |
| `invite_abuse_signals`    | rolling fraud signals (HMAC'd subjects, never plaintext)                                             |
| `invite_analytics_events` | funnel + virality events                                                                             |

The host adds **nothing** to its own schema — an accepted invite writes into the
existing `project_memberships` table (via the provisioner) and assigns an
existing Spatie role.

The **grant** is a pure value object (`TenantGrant`): `tenantId`, `role`,
`projects[]`, `projectRole`, `scopeAllowlist`. An invite can carry several, so
one code provisions across one *or more* tenants ("teams") at once.

## Decision rationale

The load-bearing choices (ADR-style; cross-links to [the ADR
index](/architecture/decisions)):

* **Reuse the standalone engine, bind the seams — don't fork it inline.** The
  invite engine is general-purpose and battle-tested; AskMyDocs is one consumer.
  Keeping it a package means its concurrency-safety and anti-abuse logic improve
  for every consumer at once, and AskMyDocs only owns the \~70-line provisioner +
  the bindings.
* **Atomic redemption is the engine's, and it is not negotiable.** Seat-count
  safety comes from a single conditional `UPDATE … WHERE current_uses <
  max_uses` plus `UNIQUE(code_id, redeemer_id)` — never a read-then-write. Two
  concurrent redemptions of the last seat cannot both win (mirrors AskMyDocs's
  own R21 "security invariants are atomic or absent").
* **GRANT-never-REVOKE provisioning.** `ProjectMembershipProvisioner` uses
  `firstOrCreate` on `(tenant_id, user_id, project_key)`. A pre-existing
  membership at a higher role is **never** downgraded; an invite can only *raise*
  access. This makes redemption safe to replay and safe to over-grant.
* **Best-effort provisioning.** The redemption commits first; provisioning runs
  after. A provisioning fault is logged (with the exception class for triage) and
  swallowed — a transient DB hiccup while writing a membership must not roll back
  a redemption the user already sees as successful.
* **`INVITE_REQUIRED` defaults OFF (R43 both-states).** Installing the package
  does not silently turn AskMyDocs into a closed beta. The signup gate is strict
  opt-in; both the OFF path (registration unchanged) and the ON path (code
  required) are covered by tests.
* **The 9 invite tables stay package-owned.** They are not added to the host
  `TenantIdMandatoryTest` enumeration (which only iterates `App\Models\*`) — the
  package enforces R30/R31 on its own models in its own CI, exactly as the
  `askmydocs-connector-base` tables do.

## Worked example — a launch-wave campaign that grants editor + two projects

```php theme={null}
// 1. A campaign whose codes grant the 'editor' role + membership in two projects,
//    in the current tenant.
$campaign = app(\Padosoft\Invitations\Services\CampaignService::class)->create([
    'name'  => 'Launch wave',
    'grant' => [
        'role'         => 'editor',
        'projects'     => ['hr-portal', 'engineering'],
        'project_role' => 'member',
    ],
]);

// 2. Generate 100 single-use codes for it (PHP surface; same as
//    POST /api/admin/invitations/codes and the InviteGenerateCodesTool MCP tool).
$codes = app(\Padosoft\Invitations\Services\CodeGenerator::class)
    ->generate($campaign->id, count: 100, maxUses: 1);
```

When a logged-in user posts one of those codes to `POST
/api/invitations/redeem`, the engine claims the seat atomically and then fans the
grant out to both provisioners: the user is assigned the `editor` Spatie role and
gains `member` `project_memberships` rows on `hr-portal` and `engineering` —
scoped to the redemption tenant. Re-redeeming (or a code that grants a project
the user already owns at a higher role) changes nothing: GRANT-never-REVOKE.

Read the funnel three ways (R44) — `GET /api/admin/invitations/metrics`, the
`InviteMetricsTool` MCP tool, or `MetricsService::summary()` in PHP.

## Admin UI — native, in-app

The admin surface is a **native tabbed page** at `/app/{team}/admin/invitations`,
inside the unified admin chrome and team switcher — not a new tab. Every tab
reads the same `/api/admin/invitations/*` core that the MCP tools and PHP
services use (R44 — one core, many surfaces), so the UI is a thin consumer with
no parallel backend. The `X-Tenant-Id` header rides the shared SPA client, so
every read is tenant-scoped automatically (R30).

| Tab                                             | What it shows / does                                                                                                                                                                                        | Core endpoint(s)                                                  |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| **Overview**                                    | all 11 live funnel KPIs (k-factor, acceptance / conversion rate, time-to-redeem p50/p90, distinct referrers…) + a proportional acquisition funnel; campaign + window filters                                | `GET …/metrics`                                                   |
| **Campaigns**                                   | list + create/edit with the **multi-tenant grant editor** (a Spatie role + KB projects across one or more tenants); `key` + `type` are immutable on edit; `super-admin` is rejected client- and server-side | `GET/POST …/campaigns`, `PATCH …/campaigns/{id}`, `GET …/tenants` |
| **Codes**                                       | inventory + batch generation (count 1–1000, max-uses, length, expiry) with Copy-all + CSV export, and inline-confirm revoke                                                                                 | `GET/POST …/codes`, `POST …/codes/{id}/revoke`                    |
| **Invite**                                      | send a direct invitation (recipient / channel / role / context); session-scoped sent list, since the core API has no list endpoint                                                                          | `POST …/invitations`                                              |
| **Referrals / Rewards / Waitlist / Anti-abuse** | filtered read tables; the waitlist email is masked and anti-abuse subjects stay server-hashed                                                                                                               | `GET …/{referrals,rewards,waitlist,abuse-signals}`                |

Two deliberate honesties:

* **Truncation is visible.** The core read surfaces cap at 500 rows with no
  pagination; when a table hits the cap it shows a *"first 500 rows — refine the
  filters"* notice rather than pretending the list is complete (R3/R14).
* **The "Advanced" panel link is gated on a server-truthful flag.** The
  standalone `padosoft/laravel-invitations-admin` panel (campaign builder +
  multi-tenant grant editor — the parts not yet native) is offered as an
  **"Advanced"** launcher *only when* `INVITATIONS_ADMIN_ENABLED=true`. The SPA
  learns this from the additive `features.invitations_admin` field on
  `/api/auth/me` (R27); when the mount is OFF the package route is unregistered
  and the link is hidden, so it never dead-ends on the `/admin/invitations` 404
  (R14/R43).

## Invite-only sign-up (SPA registration)

Since v8.26 the public auth UI is **entirely React**: `/login`, `/register`,
`/forgot-password` and `/reset-password` all render the SPA shell (`view('app')`
via `SpaController`) even on a hard page load, so a cache-cleared reload no longer
falls back to the old Blade auth page (those views, and the web
`PasswordResetController`, were removed).

The `/register` screen posts to **`POST /api/auth/register`** — a guest route in
the `web` middleware group, throttled `6/min` per IP (`throttle:register`, defined
in `AppServiceProvider`) so it can't be used to brute-force invite codes. The
controller is a thin HTTP adapter over the same invite core (R44) and follows a
deliberate order so the invite-only invariant can never leak:

1. **Pre-validate** the code with `CodeValidator` *before* touching `users` — an
   invalid / expired / exhausted code never mints an orphan account.
2. **Create** the account (no role yet).
3. **Redeem authoritatively** with `RedemptionService` (the atomic conditional
   `UPDATE … WHERE current_uses < max_uses` + the tagged Spatie-role / project
   provisioners). Redeem runs **outside** any DB transaction by design: on
   PostgreSQL a UNIQUE-violation aborts the connection for the rest of a
   transaction, which would poison the package's compensation follow-ups. On an
   exhausted-between-checks race the brand-new account is **force-deleted**.
4. **Floor** the account at `viewer` (layered on any grant role redeem already
   provisioned — GRANT-never-revoke), open the SPA session, fire `Registered`.

Every invite-code failure is mapped to a **422 field error on `invite_code`**
(R14 — never a 200 with an empty body) with a localized message
(`lang/{en,it}/register.php`; the machine-readable `RedemptionError` stays
English, R24). `invite_code` is **always required** at this endpoint regardless of
the `INVITE_REQUIRED` gate — the host SPA sign-up endpoint is invite-only by
product decision. The `INVITE_REQUIRED` / `invitations.invitation_required` flag is
the **package-level** gate (read by `padosoft/laravel-invitations`, not by the host
register endpoint), so flipping it does **not** open a non-invite registration path
on AskMyDocs.

## Gotchas

* **`manageInvitations` is super-admin + admin.** Issuing access-granting codes
  is an administrative act; dpo / editor / viewer are excluded. The user *redeem*
  surface (`/api/invitations/*`) only requires authentication — any logged-in
  account may redeem a code it holds.
* **Provisioning runs in the redemption tenant *and* any explicit grant
  tenants.** A grant's `tenantId` is authoritative for that slice — a single code
  can seed memberships in several tenants at once. The host provisioner writes
  each membership in **its grant's** tenant, not the request's active tenant.
* **A provisioning failure never fails the redemption.** If you don't see a
  membership after a successful redeem, check the logs for
  `invitations.provision.project_membership_failed` (it carries the exception
  class) — the redemption itself still succeeded by design.
* **Flipping `INVITE_REQUIRED=true` is a product decision, not a deploy detail.**
  With it on, registration *requires* a valid code. Verify your invite-issuing
  flow is live before enabling it, or new signups are blocked.
* **Campaign creation writes a grant — mind super-admin + immutability.** The
  campaign builder's grant editor provisions a Spatie role + KB projects across
  one or more tenants on redemption; `super-admin` is rejected (client *and*
  server) and a campaign's `key` + `type` are immutable after creation (the
  update rules don't accept them). The standalone
  `padosoft/laravel-invitations-admin` panel remains reachable via the gated
  **"Advanced"** launcher (`INVITATIONS_ADMIN_ENABLED=true`) for parity, but the
  native tabs now cover the full create/edit/send surface.
