# Invitation & Group Flow Analysis — Season Registrations

**Date:** July 17, 2026
**Purpose:** Second-set-of-eyes investigation of invitation/group edge cases reported in production (Brent, Myk cases). Reference document for reviewing the upcoming fix PR.

**Files reviewed:**
- `src/Controller/GroupController.php` — invite, nudge, delete, accept, decline, removeMember, leaveGroup, manage, myRegistrations
- `src/Controller/RegistrationController.php` — `available()` token handling, `acceptSeasonInvitationDirectly()`, pending-invitations banner
- `src/Plugin/Commerce/CheckoutPane/GroupPane.php` — checkout group step
- `src/Plugin/Commerce/CheckoutPane/TournamentTeamPane.php` — tournament equivalent (for contrast)
- `src/EventSubscriber/OrderCompleteSubscriber.php` — registration creation on order place
- `src/Plugin/Commerce/CheckoutFlow/CCSoccerCheckoutFlow.php` — step order
- `src/Entity/Invitation.php`, `src/Form/CancelRegistrationForm.php`, `src/Service/NotificationService.php`

---

## 1. Data model

**Invitation entity** (`ccsoccer_invitation` table): `inviter`, `invitee_email`, `invitee` (uid, NULL for brand-new players), `season`, `group_id`, `token` (magic link), `status` (pending / accepted / declined / expired), `responded_at`, `notified`.

**Registration entity** (group-relevant fields): `group_id`, `invited_by` (empty = group manager), `invitation_status` (none / accepted).

**Group membership is defined by** `Registration.group_id`. The Invitation table is the audit/workflow record. **The two are updated at different times by different code paths — this is the root of every reported bug.**

**Capacity accounting:** `getGroupSize()` = registrations with this group_id + invitations with status `pending`. A pending invitation reserves a spot. An `accepted` invitation is assumed to have a matching registration row — when that assumption breaks ("orphaned accept"), counts go wrong.

Checkout steps: `player_information → group_invitations → agreements → credits → review → payment → complete`. Registration entities are created only at `commerce_order.place.post_transition` (OrderCompleteSubscriber).

---

## 2. Flow map — all season paths

### Sending (manager, Manage Group page → `GroupController::invite()`)
- **Existing user** (autocomplete → `invitee_uid`): invitation created with `invitee` + `invitee_email`; email sent via `NotificationService::sendInvitation()`.
- **Brand-new player** (raw email, `uid=0`): invitation created with `invitee_email` only. **See Bug 4 — no email is ever sent on this path.**
- Duplicate checks: invitee already in a group (any group, this season) → blocked; existing non-declined invitation to this group → blocked.
- Capacity check: `getGroupSize()` (members + pending) vs `Season.getMaxGroupSize()`.

### Accepting — five distinct paths

**A. Token link, invitee NOT yet registered** (the main new-player path)
`/register?invite=TOKEN` → `RegistrationController::available()`:
1. Token stored in session (`ccsoccer_invite_token`).
2. Anonymous → redirect to login with destination back to `/register?invite=TOKEN`.
3. Logged in, no existing registration → season auto-added to cart → checkout.
4. `GroupPane` (step 2) matches session token → "Yes, join X's group" radio.
5. **On pane submit** (`GroupPane::submitPaneForm`): invitation marked `accepted`, all other pending invitations for the season marked `declined`, token cleared from session, selection stored in `order->data['ccsoccer_group_selections']`.
6. **On order place** (steps later, possibly never): `createSeasonRegistration()` reads order data and creates the registration with `group_id`/`invited_by`.

**B. Token link, invitee ALREADY registered**
`available()` finds an existing registration → `acceptSeasonInvitationDirectly()`: sets group fields on registration + marks invitation accepted + declines others, all in one request. **Atomic — correct.**

**C. "Accept Invitation" button** (register-page banner or My Registrations, registered user)
POST `/invitation/{id}/accept` → `GroupController::acceptInvitation()` → `acceptSeasonInvitation()`: verifies `invitee_email` matches current user, status pending, groups not locked; sets group fields + marks accepted + declines others. **Atomic — correct.** If user not yet registered, redirects to `/register` with "complete registration first" (no token carried; relies on path D matching by email).

**D. Registers without token, has pending invitation(s)**
`GroupPane` finds pending invitations by email → radio list + "Figure out group later".
- Chose a group → **same early-accept behavior as path A step 5.**
- "Figure out later" → invitations stay pending; `OrderCompleteSubscriber` explicitly keeps them pending so the player can use path C afterward. **This "register first, accept later" path works — as long as nothing accepted the invitation prematurely.**

**E. Tournament token path (contrast — the correct pattern)**
`TournamentTeamPane` stores `{action: 'token_accept', token_invitation_id}` in order data only. `OrderCompleteSubscriber` adds the player to the team, re-checks capacity post-payment, and marks the invitation accepted **only after the player was actually added**. The season pane predates this pattern and was never brought in line.

### Removal / cleanup paths
| Action | Registration row | Invitation row |
|---|---|---|
| Manager deletes pending invite (`deleteInvitation`) | n/a | **Deleted** (pending only — refuses accepted) |
| Manager removes member (`removeMember`) | group_id/invited_by/invitation_status cleared | **Untouched — stays `accepted` (Bug 2)** |
| Member leaves (`leaveGroup`) | cleared | accepted → declined ✓ |
| Invitee declines (`declineInvitation`) | n/a | → declined ✓ |
| Player cancels registration (`CancelRegistrationForm`) | status → cancelled, **group fields NOT cleared** | untouched (Edge E6) |

---

## 3. Confirmed bugs (validates the other developer's read)

### BUG 1 — Invitation accepted at checkout step 2, not at order completion ⬅ Brent
`GroupPane::submitPaneForm()` calls `$invitation->accept()` and declines all others when the user clicks Continue on the Group step — 3 steps and a payment before the order is placed. The other dev's gut ("token link is flagging accepted before actual checkout finishes") is **correct**, with one refinement: it's not the token link itself (`available()` doesn't touch status for unregistered users) — it's the pane submit. Applies to path D too, not just token arrivals.

If the user abandons checkout after step 2 (or payment fails and they never return, or cron deletes the abandoned cart):
- Invitation stuck `accepted`; no registration exists.
- Every surface that lists invitations filters `status = pending` → **invite vanishes** from the register banner, My Registrations, and the pane. This is Andrew's "invitation link might disappear" case.
- Re-clicking the email link: `available()` looks up `token + status=pending` → no match → **silently** falls through to the plain register page. No message, season not added with invite context. User registers normally → registration created with **no group** (order data empty). End state = exactly Brent: invitation `accepted` days ago, fresh registration with no group.
- Invitee cannot self-recover: `acceptInvitation()` requires pending.
- Manager cannot recover either: `deleteInvitation()` refuses non-pending (Bug 3), and re-inviting is blocked by the non-declined duplicate check — "This player has already accepted an invitation."

### BUG 2 — `removeMember()` leaves the accepted invitation behind ⬅ Myk
Removing a member cleans the registration row but never touches the invitation, leaving an orphaned `accepted` row. `leaveGroup()` does the cleanup correctly (accepted → declined) — `removeMember()` simply lacks the equivalent block. Consequences: Myk-style stale `accepted` rows; manager blocked from re-inviting the same player (duplicate check counts non-declined).

Note: dev's phrasing was "deleting that invite cleans up the reg table but not fully the invitation table." `deleteInvitation` itself fully deletes the row (pending only) and never touches registrations — the observed data is what `removeMember` produces. Same net finding, slightly different attribution.

### BUG 3 — No cleanup path for orphaned accepted invitations
`deleteInvitation()` hard-refuses anything non-pending. Combined with Bugs 1–2 there is no UI way to clear an orphaned `accepted` row; it takes manual DB/drush surgery (what the dev did "fixing the data for now").

### BUG 4 — Brand-new invitees never receive the invitation email
`invite()` only calls `sendInvitation()` inside `if ($invitee_uid)`. Email-only invitees (uid 0 — "New player - will send invite to create account") get an invitation row and **no email**. `nudge()` has the same guard yet still updates `notified` and reports "Reminder sent." — silent false success. `NotificationService::sendInvitation()` requires a `UserInterface`, and no other code path emails a raw `invitee_email`. Unless these players are contacted out-of-band, they can never find their magic link. (Screenshot case "Matt Willis — Pending (Not Registered)" is likely affected.)

---

## 4. Additional edge cases the PR should be checked against

**E1 — Back button on the Group step wipes the accepted selection.** After accepting at pane submit, the invitation is no longer pending. If the user navigates Back to the Group step and Continues again, `buildPaneForm` finds no pending invitations, renders the "no invitations" branch, and submit **overwrites `ccsoccer_group_selections` with NULL**. Order completes → registration has no group, invitation stuck `accepted`, and the user's other invitations were already declined. Total loss with a fully completed checkout. Any fix that keeps pane-time acceptance must rebuild the pane from saved order-data selections, not from pending status.

**E2 — Phantom-capacity overflow race.** Early accept frees the pending slot before the member exists: invite A (1 spot left, now 0 pending-reserved) → A accepts at pane, abandons → invitation accepted, no registration → Manage Group now shows a free spot → manager invites B → B completes → group full. A later completes the original cart (Commerce carts persist): `createSeasonRegistration()` applies `group_id` from order data **with no group-size check** → max+1 members. Tournament path re-checks `isFull()` post-payment; the season path has no equivalent guard.

**E3 — Token accept never verifies invitee identity.** Paths A/B key off the session token only; `invitee_email` is never compared to the logged-in user (path C does compare). A forwarded email lets anyone join the group, and the invitation row keeps the original email, so downstream email-based queries (banner display, decline-others, leaveGroup cleanup) miss it — creating more orphan potential. May be tolerated by design for new players registering under a different address, but it's worth an explicit decision.

**E4 — Cancelled registrations still occupy groups.** `CancelRegistrationForm` sets status `cancelled` but leaves `group_id`/`invited_by` intact, and neither `manage()`'s roster query, `getGroupSize()`, nor GroupPane's `getGroupMembers()` filter by status. A cancelled player still shows on the roster and consumes a group spot; the manager cannot remove the phantom (removeMember works, but nothing prompts it) and cannot re-invite a replacement if counts are full. Related: if the **manager** cancels, members keep pointing at the group, pending invitations stay live, and no one has manage rights (manage() blocks cancelled registrations).

**E5 — No expiry.** Status `expired` is defined but never set anywhere. Pending invitations reserve capacity forever until manually deleted or declined.

**E6 — Accept paths have no group-size check.** `acceptSeasonInvitation()` / `acceptSeasonInvitationDirectly()` / GroupPane accept never verify the group still has room. Normally safe because pending invitations reserve spots — but that invariant is exactly what Bugs 1/2 and E4 break, so overflow is reachable today.

**E7 — Silent dead-token UX.** Any non-pending invitation makes the emailed link behave as if it never existed (no message at all). Even after the core fix, links from declined/superseded invites will do this; a friendly "this invitation is no longer available" message would prevent the confusion that started this investigation.

---

## 5. PR review checklist

The dev's proposed direction (validate that acceptance happens at true completion) is right. A complete fix should:

1. **Move season acceptance to order completion** — pane stores selection in order data only; `OrderCompleteSubscriber::createSeasonRegistration()` marks the invitation accepted and declines others, mirroring the tournament `token_accept` branch (accept only after the registration row actually exists). Decline-others must also move (currently fires at pane submit).
2. **Rebuild the pane from saved order-data selections** (E1) — after the fix the invitation stays pending mid-checkout, which mostly solves it, but verify Back → Continue keeps the selection, and that the same invitation showing in *two* concurrent carts / the banner can't double-accept (accept at completion should be guarded with `isPending()` like the tournament branch).
3. **Add post-payment group-size guard** for seasons (E2/E6), analogous to the tournament `isFull()` check, with an order flag (`ccsoccer_*`) for admin follow-up rather than silent overflow.
4. **Fix `removeMember()`** to decline (or delete) the member's accepted invitation — copy the `leaveGroup()` block. Note `leaveGroup` matches by current email; for token-accepted invites with mismatched email (E3) match by `invitee` uid or `group_id + invitee` instead.
5. **Data repair migration/update hook** — find invitations `status=accepted` with no matching registration (`invitee_email`/`invitee` + season + group_id) and reset to pending (link works again) or declined. Also stale `accepted` rows for removed members (Myk). Verify the PR includes this, not just the code fix — prod data is already inconsistent.
6. Worth raising even if out of scope for this PR: Bug 4 (email-only invitees get no email — arguably launch-relevant since group emails to new players are a primary flow), `deleteInvitation` cleanup restriction (Bug 3), cancel-registration group cleanup (E4), dead-token messaging (E7), token identity check (E3).

**Regression tests to run against the PR** (LOCAL):
- Token link → full checkout → invitation accepted + registration grouped ✔
- Token link → abandon at Agreements → invitation still **pending**, banner/link still work, manager still sees Pending and can delete
- Token link → abandon → return via same link → completes normally
- Register via checkout, accept invite on Group step, click **Back**, Continue, complete → still grouped
- "Figure out later" → accept from My Registrations afterward ✔ (must not regress)
- Manager removes member → member's invitation declined → manager can re-invite same player
- Two members accepting the last spot concurrently → second flagged, not overflowed
- Already-registered user clicks token link (path B) → still atomic

---

## 6. Recommended fix for BUG 4 (email-only invitees) — ✅ IMPLEMENTED July 17, 2026

Implemented as recommended (uncommitted working-tree changes, 3 files):
- `NotificationService::sendInvitationEmail()` — new method, handles both invite and reminder wording via `$is_reminder` flag; reply-to = inviter; returns bool.
- `GroupController::invite()` + `nudge()` — else-branches for email-only invitees; `notified` stamped only after successful send; warning message on send failure (invite stays saved, Send Reminder retries).
- `drush ccsoccer:send-pending-email-invites [--dry-run]` (alias `ccs-invites-repair`) — one-time repair for existing pending email-only invitations (Matt Willis et al.).

Deploy: code only, `drush cr`. Test on LOCAL first: invite a board-member email address that has no account (allowlist will let it through to Mailpit); verify email arrives with working magic link, `notified` set; invite a non-allowlisted address and verify the new warning appears (expected on non-prod). Then run the repair command with `--dry-run` on PROD before the real run.

### Original recommendation

Do not create stub accounts. Add `NotificationService::sendInvitationEmail(string $email, UserInterface $inviter, string $invite_url, string $context_name, bool $is_tournament, ?string $team_name): bool` — same subject/body as `sendInvitation()`, delivered via `sendEmail()` directly (no user → no preferences to honor, no SMS; the non-production board allowlist in `sendEmail()` still applies). Set reply-to to the inviter's email.

Wire it into the `else` side of the `if ($invitee_uid)` guards in both `invite()` and `nudge()`. While there, fix the false-success reporting: set `notified` and show "sent" messages only when the send returns TRUE (today `invite()` stamps `notified` at entity creation and `nudge()` stamps before sending — both should stamp after success, which also makes the 48-hour nudge throttle honest).

Data repair: one-time drush command / update hook — load invitations with `status = pending` AND `invitee` IS NULL, send each via the new method, stamp `notified`. Covers Matt Willis and any others silently affected.

Copy tweak: the email says "register and join" — add a line that brand-new invitees will create a free account as part of registering, matching what the login redirect actually asks of them.

Separable from the token-timing fix (Bug 1): no overlap with `GroupPane`/`OrderCompleteSubscriber`, so it can ship as an independent PR.

## 7. One-line summary per reported prod case

- **Brent**: accepted at checkout step 2 (Bug 1), abandoned before payment; invite vanished (pending-only queries); registered later with no group. Data fix: his invitation row was manually corrected.
- **Myk**: removed from group via `removeMember` (Bug 2); registration cleaned, invitation left `accepted`; blocks re-invite.
- **"Several users with no explanation yet"**: candidates are E1 (back-button wipe), E4 (cancelled-but-grouped), and abandoned-checkout Bug 1 variants where the user never returned at all.
