# CC Soccer D11 - Session Handoff
**Date:** July 18, 2026
**Branch:** `feature/update_registration_page` — 2 commits, tested on LOCAL, needs PR + deploy (July 8 work, still pending)

Earlier session detail (June 23 – July 5) archived in `archive/SESSION_HANDOFF_2026_07_08.md`. July 17–18 sessions archived in `archive/SESSION_2026-07-18.md` and `archive/SESSION_2026-07-18b.md`; July 20 daytime sessions archived in `archive/SESSION_2026-07-20.md` (near-duplicate snapshot noted in `archive/SESSION_2026-07-20b.md`) before this update.

---

## Session Work — July 20, 2026 (late night) — TEST verification of the invitation-timing fix (Caleb + Claude)

Both July 20 fixes (invitation timing + jersey cleanup) deployed to TEST earlier tonight. This session worked the `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` checklist live against TEST with real accounts (Myk Stoner as manager, Caleb's own account + a `Caleb@ccsoccer.com` test account as invitees), verifying against the DB directly at each step rather than trusting UI state alone.

### Results — 7 of 9 sections passed, 1 failed (found a real crash bug), 1 not started
- **A0 (zero-invitation happy path):** passed — Myk registered clean, no group fields set.
- **B (Bug 1 — abandon after selecting an invitation):** passed. Invitation confirmed `pending` all the way through Player Info → Group (selected) → Agreements, before payment. This is the core assertion of the whole fix.
- **A (full accept-on-completion flow):** passed. Same invitation flipped to `accepted` only once checkout actually completed; registration correctly linked to the manager's `group_id` on both sides (verified inviter's own registration row matches).
- **F (Bug 2 — `removeMember()` invitation cleanup):** passed. Removing the member flipped the invitation to `declined` (not left stuck `accepted`), registration group fields cleared, and — the real proof — re-inviting the same person immediately succeeded with no "already accepted" block.
- **C (token pre-selection):** passed for the core mechanic — clicking a fresh magic-link token correctly pre-selected "Join X's group" as the default radio choice, and it stayed `pending` through checkout, flipping to `accepted` only at completion, same as the non-token path. **Still not done:** the identity-check half (E3) — needs a fresh token plus a genuinely clean account (Caleb's and the test accounts all have registration history now, which routes them into the *other* broken path documented below rather than the one E3 tests).
- **D (back-button persistence):** passed, and more thoroughly than the checklist asked for. Explicit selection ("Join Caleb's group", deliberately not the token default) survived Continue → Back, survived leaving checkout entirely via Home → Register and returning, and survived clicking the *other* invitation's banner link (correctly blocked by the existing "already in cart" duplicate guard rather than silently swapping the selection). On completion, the selected invitation → `accepted`, the other → `declined`, `sendPlayerJoined` text notification fired correctly. Both invitations stayed live and selectable throughout the back-and-forth — which is the point: under the old code the first Continue would have already accepted one and declined the other.
- **G (Bug 3 — stale orphan doesn't block re-invite):** passed. Fabricated the orphan state (Myk invited `orphan-g-test@example.invalid`, then flipped it to `accepted` via SQL with no registration behind it), then re-invited the same address — went through cleanly, new pending invitation created, no block. `invitationHasMatchingRegistration()` working as designed. **Note:** required a `drush cr` first — the raw SQL write wasn't visible to `invite()`'s entity lookup until cache was rebuilt, which produced a confusing "already sent an invitation" (the *pending* message, not the accepted one) on the first attempt. Third time tonight this exact staleness pattern bit us.
- **E (post-payment group capacity guard): FAILED — crashed checkout.** See the dedicated section below. The guard's *logic* worked correctly (right group, right user, correct "player NOT added" decision, correctly flagged the order), but completing checkout under that condition produced a PHP fatal (`Maximum call stack size ... reached. Infinite recursion?`) and a white error page at `/checkout/37/payment`.
- **H (tournament flow untouched, sanity check):** not started.

### 🔴 E's failure: infinite recursion in `onOrderPlace()` — root cause found and fixed same session

**Symptom.** Kelly Smith's checkout, with her group deliberately capped at 2 (already full), crashed at the payment step. Watchdog showed `"Order 37 completed, creating registrations"` and `"Group af71cb53-... was full at checkout completion"` alternating repeatedly, all within the same second, ending in a stack-exhaustion fatal in `Map->get()`.

**Root cause.** `commerce_order.place.post_transition` fires *after* the order has already been written, which is why every "flag the order and bail" branch calls `$order->save()` itself — a bare `setData()` at that point wouldn't persist. But saving the same order object again from *inside* the handler re-presents the same draft→completed state delta to state_machine, which re-dispatches the place transition and re-enters `onOrderPlace()` synchronously, on the same call stack. It hits the same branch, saves again, recurses — unbounded.

**The detail that explains why this never surfaced before.** The save at the *end* of `onOrderPlace()` has the identical risk and has never crashed, purely because of statement ordering: it sets `ccsoccer_completion_processed` on the object *before* saving, so the re-entry hits the persistent idempotency guard at the top of the method and returns immediately — depth 2, terminated. The mid-method saves have no such protection, because that flag isn't set yet, so re-entry sails straight past the guard. (Confirmed by the logs: repeated full `"Order 37 completed"` messages rather than `"already processed; skipping duplicate"` proves re-entry from the middle.)

**Scope is wider than the new code.** Tonight's `ccsoccer_group_capacity_exceeded` branch is what actually crashed, and it's ours — but it copied an existing pattern. **Five pre-existing branches share it**: `season_inactive`, season `capacity_exceeded`, `team_name_collision`, `tournament_full`, `team_capacity_exceeded` (plus `ccsoccer_credit_shortfall` in `processCredits()`). Tonight's manual test appears to be the first time *any* of these branches has actually executed on a completed order, on any environment. Season-capacity in particular is not an exotic race — a popular season filling while several people are mid-checkout is ordinary — so this was a latent landmine, not purely a new bug.

**Fix implemented (same session, `OrderCompleteSubscriber.php`, +64/-0):** a request-scoped `protected static $processingOrders` array. `onOrderPlace()` now checks it on entry (logs a warning and returns if the order is already being processed), registers the order ID, delegates the entire original body to a new `doOrderPlace()` method, and releases in a `finally` block so a thrown exception can't leave an order stuck. Chosen over removing the mid-method saves because those saves exist for a real reason — durability of the flag at the moment of detection, in branches that `return` immediately after — and removing them would trade away that guarantee. This approach keeps the guarantee, fixes the crash regardless of which save triggers the re-dispatch, and protects any save added in future. Purely additive, no business logic touched, `php -l` clean.

**Verification still needed on TEST:** re-run Kelly's checkout with the group still capped at 2. Expect: checkout completes normally, registration created with `group_id` NULL, invitation still `pending`, order flagged `ccsoccer_group_capacity_exceeded`, **and one new warning reading "nested place transition detected ... skipping re-entrant call."** That warning is the actual proof the guard fired — if the flag is set correctly but no warning appears, the diagnosis was wrong and this needs Xdebug rather than another theory.

**Not fully understood, and worth naming:** *why* saving the order re-presents the state delta to state_machine rather than being seen as a no-op. The guard makes that question moot in practice, but it hasn't been traced to Commerce/state_machine internals.

**TEST data left behind:** order 37 exists in a crashed/incomplete state and Kelly (uid 92740) has no registration for season 43. Season 43's `max_group_size` is still **temporarily set to 2** for this test — **restore it to its original value (4) once E is re-verified.**

- **`GroupController::acceptSeasonInvitation()` has no capacity check** — confirmed live tonight. With season 43's group max temporarily at 2 and the group already at 2/2, Kelly clicked Accept from her Manage Group page and was added anyway, putting the group at 3/2. Same class of gap as the checkout-side one we just guarded: capacity is enforced at order completion but not in the accept-from-My-Registrations paths (`acceptSeasonInvitation()`, `acceptTeamInvitation()`, and `acceptSeasonInvitationDirectly()`/`acceptTeamInvitationDirectly()`). Note the UI already contradicts itself here — the manager's view correctly says "Your group is full. No more invitations can be sent" while the invitee's view still offers an Accept button into that same full group.
- **Missing user-facing messaging around group-full (design decision made, not yet built).** Two places need it:
  1. **In checkout (`GroupPane::buildPaneForm()`):** if a pending invitation's group is already full, still show it but disabled — e.g. "Join Caleb Cross's group (full)". Decided against hiding it silently (invitee got an invite email; a missing option with no explanation is more confusing than a disabled one) and against auto-declining (S2 in Andrew's audit was exactly that bug — over-inviting and seeing who registers first is a legitimate workflow, so invitations must stay pending).
  2. **At checkout completion:** when the order-completion capacity guard fires, the player currently gets a totally normal confirmation and has no idea they weren't placed in the group. Needs a message on the completion page along the lines of "the group you selected filled up — your registration is confirmed, but you're not in a group yet; you can still form your own group." This is the more important of the two — the pane change makes the race rarer, but the guard firing silently is the part that actually misleads someone.
  Note the pane fix does NOT replace the order-completion guard: a group can fill between pane selection and payment landing. That race is exactly what the guard exists for and it stays.

### Real findings surfaced during testing, none of them regressions from tonight's fix — all pre-existing
- **E4 confirmed as a real, live issue** (previously only theoretical in the analysis doc): a cancelled registration keeps its old `group_id`/`invited_by` set, which then blocks a legitimate re-invite via `invite()`'s "already in another group" check. Hit directly during setup for test B — had to manually null out `group_id`/`invited_by` on a cancelled registration via SQL before Myk could invite Caleb. Worth a real fix at some point (not urgent, not part of tonight's scope).
- **`userSearch()` (invite autocomplete) has no status filter**, unlike `myRegistrations()`/`isFirstTimeRegistration()` which correctly exclude cancelled. A cancelled-only registrant still shows a "Registered" badge in the invite-player autocomplete dropdown. Confirmed cosmetic only — doesn't block sending the invite — but worth cleaning up so the badge isn't actively misleading.
- **Override mechanisms: two of them, inconsistently wired — CORRECTED diagnosis.** Last night's note here blamed the `permanent_override` *role*; that was wrong, and the wrong thing was tried (`drush user:role:add`). The actual mechanism is a **`field_permanent_override` checkbox on the user profile**, not a role. But checking it still didn't get Kelly Smith through Mens 35+ registration, and the real reason is worse: **there are two separate eligibility functions in `RegistrationController` and only one of them honors the checkbox.** `userMeetsSeasonRequirements()` (controls whether a season is *listed* on `/register`) checks `field_permanent_override` and returns TRUE immediately. `checkUserAge()` (the actual *enforcement* gate, called from `addSeasonToCart()`) never looks at that field at all — it only recognizes a formal per-season override entity via `ccsoccer.override_manager`. Net effect: the checkbox makes a season *appear* registerable without actually being registerable. Confirmed live tonight; worked around by editing the test user's DOB directly. **Fix needs a product decision, not just refactoring:** extract one shared eligibility method both call, and decide whether to keep both override mechanisms (blanket profile checkbox + formal per-season entity) or consolidate to one. Same "duplicate logic that drifted apart" pattern as the jersey panes and the two order-completion pipelines found earlier today.
- **`acceptSeasonInvitationDirectly()` / `acceptTeamInvitationDirectly()` never clear the session invite token**, unlike `GroupPane::submitPaneForm()` which does. `RegistrationController::available()` writes the token to session unconditionally at the top, before any branching — so when an already-registered user clicks a link, the token is stored, the direct-accept path runs, and the token is left behind in the session indefinitely. Surfaced tonight as a confusing but harmless symptom: a stale token from last night's Kyle incident was still in Caleb's session, and because masquerading reuses the same PHP session, it pre-selected an invitation during a later test that looked intentional but wasn't. One-line fix, low severity, worth doing alongside the identity-gap fix below since it's the same file and same methods.
- **`/register?invite=TOKEN` did not auto-add the season to cart** as the flow is documented to work (`INVITATION_FLOW_ANALYSIS.md`'s path-A description). Landed on the register page with the pending-invitation banner visible, but had to manually click Register for the season rather than being carried straight into checkout. The token still worked correctly once inside checkout (session-based lookup, not cart-dependent), so this didn't block testing — but the documented behavior and actual behavior don't match. Not something tonight's changes touched (`RegistrationController.php` wasn't in this fix's file list) — pre-existing, worth understanding separately.
- **`/opt/cpanel/composer/bin/composer` missing/moved on the server** — `ccsDeploy`'s composer step failed with "Could not open input file" partway through tonight's second deploy, breaking the `&&`-chained command (subsequent `ccsUpdb`/`ccsCim`/`ccsCr` had to be run manually afterward). Not blocking tonight since neither deploy touched `composer.json`, but will block the next PR that does. Needs a look: `ls -la /opt/cpanel/composer/bin/composer` / `which composer`.
- **`drush config:status` crashed** (`Invalid option specified: "bold"`) on first attempt on TEST tonight — unrelated Symfony Console formatting bug, not a real config problem (confirmed via direct SQL and `--format=csv` instead). Cosmetic/environment issue, not investigated further.

- **NEW BUG FOUND (not from tonight's fix — pre-existing, in code we never touched): `RegistrationController::acceptSeasonInvitationDirectly()` (and its team-invite twin `acceptTeamInvitationDirectly()`) has the same E3 identity gap we closed tonight in `GroupPane`, just in the already-registered token path.** Confirmed live, not theoretical: Caleb (already registered for season 43, including one cancelled + one paid registration) clicked a token link addressed to Kyle Genevay's email. Two compounding root causes, both confirmed by reading the actual code:
  1. `available()`'s "is this user already registered" lookup (`loadByProperties(['player'=>uid,'season'=>id])`) has no status filter — same pattern as `userSearch()`/`isFirstTimeRegistration()`/`invite()`'s eligibility check, now the 4th confirmed instance of this exact bug shape tonight. It grabbed Caleb's *cancelled* registration (5073) instead of his real paid one (5101).
  2. `acceptSeasonInvitationDirectly()` never compares the invitation's `invitee_email` to the logged-in user at all — so it proceeded to accept Kyle's invitation using Caleb's identity, onto Caleb's cancelled registration. Real effects: invitation flipped to `accepted` (Kyle's real path now broken until reset), "joined group" notification fired to the inviter, green success message shown, then a red "registration has been cancelled" error on redirect (from `GroupController::manage()`'s existing cancelled-status guard) — both messages in the screenshot fully explained, nothing mysterious.
  3. **Cleaned up on TEST:** invitation 26 reset to `pending`, registration 5073's group fields cleared back to NULL/none. Confirmed via `SELECT` after.
  4. **Not fixed yet, not blocking tonight's PROD decision** — real scope, but narrow real-world trigger (requires clicking a token addressed to someone else while already holding *any* registration, even cancelled, for that season). Worth its own scoped fix: add the same email-match guard here that already exists in `acceptSeasonInvitation()`/`acceptTeamInvitation()` (the "Accept" button path, which already does this correctly), and add a status filter to the `available()` lookup.

### Status
- **✅ E re-verified and PASSING on TEST after the recursion fix.** Kelly's checkout completed cleanly: registration 5104 created `paid` with `group_id` NULL and `invitation_status` none, invitation left `pending`, order 38 flagged `ccsoccer_group_capacity_exceeded`, and — the confirming detail — exactly one watchdog warning (wid 189209) reading "nested place transition detected while already processing; skipping...". One entry, not many: recursion terminated at depth 2 as designed. The diagnosis was correct, not a coincidental non-reproduction.
- **8 of 9 checklist sections now pass.** Only H (tournament sanity) and C's identity half remain, both non-blocking.
- **The PROD blocker is cleared.** Code deployed — confirm PROD deploy status at the start of the next session.
- TEST cleanup done: season 43's `max_group_size` restored to 4.
- TEST data left behind (harmless): order 37 in a crashed/incomplete state from the pre-fix attempt; Kelly is in Caleb's group at 3/2 from the Accept-button test above.

---

## Session Work — July 20, 2026 (later) — Has Jersey zombie-field and legacy duplicate-pipeline cleanup (Caleb + Claude)

Andrew asked whether jersey-requirement logic uses a previous-season heuristic and whether the profile Has Jersey checkbox still means anything. Investigation confirmed both field_has_jersey and field_jersey_size on the user entity are dead, written in two places, read nowhere, and both writers were themselves broken. While tracing the writers, found the second writer was inside a legacy hook that a deeper check showed was entirely dead, not just its jersey lines. Scope grew from remove two field writes to remove the whole legacy pipeline plus finish an old incomplete field deletion, all narrowly justified by evidence.

### Jersey requirement itself, confirmed correct, untouched
PlayerInfoPane (the pane actually wired into checkout) determines jersey eligibility from registration history, isFirstTimeRegistration, zero prior ccsoccer_registration rows, not from any checkbox. Working as intended, no changes made here. One pre-existing nuance decided: a player whose only registration was cancelled still counts as returning and wont be prompted for a jersey at checkout. Decision: leave as-is, handle via the standalone jersey product page or manual admin action if it comes up.

### ccsoccer_commerce_order_paid_in_full, confirmed entirely dead, deleted in full
This function plus its _ccsoccer_process_tournament_registration helper implements a hook that does not exist in Commerce 2.x, this sites version. Commerce fires via the commerce_order.place.post_transition event instead, which OrderCompleteSubscriber already listens to. Confirmed dead three independent ways before deleting: (1) zero rows in PROD watchdog for its unconditional log message, (2) grep of vendor/drupal/commerce star src for the hook name returns nothing, (3) grep of the custom module for paid_in_full only finds the function's own definition, no invokeAll anywhere. Deleted entirely from ccsoccer.module, not just its jersey lines. This also removes a full second, less-safe copy of season and tournament registration creation that was never actually running.

### field_has_jersey, field_jersey_size, field_notification_prefs: finishing an incomplete May 6 fix
All three were already deleted once in ccsoccer_update_9061, same batch as field_credits_balance, but only field_credits_balance got the belt-and-suspenders follow-up in ccsoccer_update_9063 on May 6 after its config/sync YAMLs were found still resurrecting it via drush cim. These three had the identical leftover-YAML problem, unfixed until now.

Registration.jersey_size, a separate field on the registration entity, was also always saving NULL since PlayerInfoPane stores the cart key as jersey_variation while both writers read jersey_size. Decision: delete the write, not fix the key, since the jersey report already reads accurate sizes from order items via SQL.

JerseySelectionPane.php confirmed genuinely dead, a separate abandoned earlier attempt from PlayerInfoPane: its plugin ID is not in the checkout flow config's panes list at all, so it can never render. Deleted.

### What changed
1. ccsoccer.module: deleted ccsoccer_commerce_order_paid_in_full and _ccsoccer_process_tournament_registration in full.
2. ccsoccer.module: removed field_has_jersey from ccsoccer_form_user_form_alter's admin-only-fields list.
3. OrderCompleteSubscriber.php onOrderPlace: removed the jersey-writing block, kept self-score, renamed the shared jersey_handled guard to profile_update_handled.
4. OrderCompleteSubscriber.php createSeasonRegistration: dropped the always-NULL jersey_size line from the registration create array.
5. JerseySelectionPane.php moved to archive (archive-via-move, no delete tool available).
6. Moved 6 leftover YAMLs to archive (storage plus instance times three fields).
7. Stripped all three fields from the three shared user display YAMLs (dependencies and content/hidden sections).
8. Added ccsoccer_update_9070 to ccsoccer.install, same belt-and-suspenders pattern as 9063, covering all three fields. LOCAL had none of the three in active config or as DB tables, confirmed via direct SQL, not just drush status commands which gave misleading readings at one point. TEST and PROD are the real target, user 94291's manually-checked value lives there.

### Verification on LOCAL
php -l clean on all three touched PHP files. drush updb ran 9070 cleanly, no-op on LOCAL as expected. drush cim picked up the three display-YAML edits. drush config:status shows no differences between DB and sync directory. Direct config dump confirms none of the three fields appear anywhere in active config.

### Status
- Code and config: complete on LOCAL, verified clean, uncommitted.
- Not yet done: functional smoke test, profile edit form load and one season checkout, planned before commit.
- Deploy note, different from the invitation-timing fix: this one needs both cim and updb, ccsDeploy and ccsUpdb and ccsCim and ccsCr, since this genuinely touches config and adds a real update hook. On TEST and PROD watch the updb output for 9070, expect it to actually list all three field names as removed, not not found like LOCAL.
- Commit as its own commit, separate from the invitation-timing fix.

---

## Session Work — July 20, 2026 — Season invitation timing fix, code complete on LOCAL (Caleb + Claude)

Built items 1–4 and 6 of the `INVITATION_FLOW_ANALYSIS.md` Section 5 checklist — the actual fix for Brent/Myk, which Andrew's branch (above) did not touch. Item 5 (bulk data-repair drush command) deliberately deferred to its own phase — not part of this pass.

### What changed (3 files, all in `web/modules/custom/ccsoccer/src/`)

1. **Bug 1 — accept moved to order completion** (`Plugin/Commerce/CheckoutPane/GroupPane.php` full rewrite, `EventSubscriber/OrderCompleteSubscriber.php::createSeasonRegistration()`): `GroupPane` no longer calls `$invitation->accept()`/`decline()` at pane-submit — it only stores the selection on the order. Accept/decline now happens in `createSeasonRegistration()`, gated on `$invitation->isPending()`, mirroring the tournament `token_accept` pattern exactly. An abandoned checkout after selecting a group now leaves the invitation untouched (`pending`) instead of falsely `accepted`.
2. **GroupPane collapsed to one code path (item 6):** dropped the separate token-branch UI (accept/decline confirm + fallback radios) entirely — a token invitation now just sets the default radio selection in the same unified pending-invitations list every registrant sees. Removed `buildFallbackOptions()`, `declineOtherInvitations()`, `getGroupMembers()` (all dead code once the split UI was gone).
3. **E3 identity check, closed as a side effect of #2:** the token only defaults a selection if that invitation survives `getPendingInvitations($user_email, ...)` — i.e. only if the logged-in user's email matches the invite. A forwarded magic link to a different account no longer does anything.
4. **E2/E6 post-payment capacity guard**, new in `createSeasonRegistration()`: confirmed-members-only count (not pending-inclusive) against `Season::getMaxGroupSize()` right before accepting. If already full, invitation stays pending, registration is still created (payment captured) but without a group, order flagged `ccsoccer_group_capacity_exceeded` for admin follow-up.
5. **Bug 2 — `GroupController::removeMember()`:** now declines the removed member's `accepted` invitation (copies `leaveGroup()`'s pattern, keyed to the removed player rather than the current user). Fixed for **both** season and tournament branches — tournament had the identical gap (drift audit D8).
6. **Bug 3 (narrow) — `GroupController::invite()`:** new `invitationHasMatchingRegistration()` helper; the duplicate-invite check no longer blocks a re-invite when the existing `accepted` invitation has no matching registration (a stale orphan). The orphaned row itself is left alone — inert until a Phase 2 bulk sweep (item 5).

All three files archived before editing: `archive/GroupPane_2026-07-18.php`, `archive/OrderCompleteSubscriber_2026-07-18.php`, `archive/GroupController_2026-07-18.php`. `php -l` clean on all three.

### Status
- **Code:** complete on LOCAL, uncommitted.
- **Testing:** not yet run. Full checklist (regression, Bug 1 abandon-checkout proof, token+identity, back-button, capacity guard, removeMember both types, Bug 3 orphan contrast case, tournament-untouched sanity, **plus the zero-invitation happy path — highest blast radius if broken, since `createSeasonRegistration()` runs on every season order**) is in `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` (delivered as an artifact this session — not yet saved into the repo).
- **Next:** commit + push to `main`, deploy to TEST, Caleb + Andrew test there together per the checklist. Risk assessed as moderate-to-high (money path, every season order flows through the touched code) — sections 1–3 of the checklist (zero-invitation path, Bug 1 proof, core accept flow) are the ones that must pass before PROD; the capacity guard (section E) is a safety net for a rare race and shouldn't gate the deploy.

### PROD data — two more Bug 1 victims found and fixed while the bug is still live (fix not deployed yet)

Same mechanism as Brent/jenaepackard/cysaallstar from the original investigation, confirming this is ongoing, not historical:

- **Michael Ponomaroff (uid 92087) / Tyler Cota (uid 94607), season 47 (Mens 35):** Tyler's invitation showed `accepted` but his registration (id 5316) had landed in a different, self-generated `group_id` with `invited_by` NULL — identical pattern to Brent. Checked Tyler's phantom group for other invitees first (none). Fixed: `UPDATE ccsoccer_registration SET group_id = '20118175-c8c2-4d75-9210-36e27c211df0', invited_by = 92087, invitation_status = 'accepted' WHERE id = 5316;` + cache rebuild.
- **Broader scan run against PROD** (season-only, excludes team invitations — join on `r.registration_type = 'season'` + `i.team IS NULL`) turned up two more with no registration at all yet:
  - Invitation 131 (Tenayaney22@gmail.com, season 48) — reset to `pending`.
  - Invitation 140 (garret.mcelveny@gmail.com, season 47) — reset to `pending`.
  - Both via `UPDATE ccsoccer_invitation SET status = 'pending', responded_at = NULL WHERE id IN (131, 140);` + cache rebuild.
- **Invitation 5 (Haley Raymer, season 43)** also matched the scan — this is the same season-43 ghost row from the original investigation. Reconfirmed: leave as-is, closed season, no practical effect.
- This scan is the read-half of item 5's eventual repair command; the write-half (link vs. reset-to-pending decision logic) is still Phase 2 work, not built as a reusable command yet.

---

## Session Work — July 18, 2026 (later) — Review of `fix/invitation_flow_analysis` (Caleb + Claude) — ✅ MERGED, validated on TEST

Reviewed Andrew's branch against the original ask (Brent/Myk group-invitation bugs). Findings:

- **Bug 1 (Brent — early accept in `GroupPane::submitPaneForm`) and Bug 2 (Myk — `removeMember()` doesn't decline the invitation) are both still OPEN.** Neither file is touched anywhere in this branch. Andrew's work is real and adjacent (D1 refund bug, Bug 4 email delivery, S2 tournament auto-decline, T6/R1 admin-tooling fixes below) but does not fix, and does not put at risk, the invitation-timing bug itself. That's still a separate PR — scope per `INVITATION_FLOW_ANALYSIS.md` Section 5 (move accept to order completion, rebuild pane from order-data selections, fix `removeMember`, data-repair migration for already-orphaned `accepted` rows).
- Reviewed the shipped fixes (S2, D1, Bug 4, T6, R1) line-by-line for regressions — none found. Each is narrowly scoped and matches its doc entry.
- Removed leftover `DEBUG:` notice-level logging in `NotificationService::send()` (not Andrew's — pre-existing noise burying the real signal in dblog, called out as H1 in `ORDER_FLOW_EDGE_CASES.md` but never fixed). Lint-checked clean.

**Merged.** `f41a3d4` on `main` (`git merge --no-ff fix/invitation_flow_analysis`, plus the debug-log-removal commit on top). Deployed to TEST (`ccsDeploy && ccsCr`, no cim/updb needed as expected) — Andrew confirmed validated and good on TEST. PROD deploy status: not confirmed in this thread — verify before assuming it's live there.

**Carried forward, status updated end of day July 20:**
- Andrew to manually refund Avi's $133 (order 87, Payments tab) and email him — **~90% confident this is done**, Caleb believes he saw Andrew do it during a shared screen session, but not confirmed via the Payments tab record itself. Worth a 30-second verify next time someone's in that order.
- The July 17+18 LOCAL test checklist below — not yet confirmed run.
- `drush ccsoccer:send-pending-email-invites` on PROD (Bug 4 data repair) — **✅ DONE**, run live on PROD.

---

## Session Work — July 18, 2026 — Admin tooling follow-ups (Claude), UNCOMMITTED

Worked through `ADMIN_TOOLING_REVIEW.md` findings with Andrew. Three resolved **as designed** (intent now documented in code so future reviews don't re-flag), two **fixed**, two **on hold**. All uncommitted, on top of the July 17 changes. Deploy: code only, `drush cr`.

### As-designed — documented in code + UI
- **TS1 (tournament format):** builder is purpose-built for the single-day SLO Friendly round-robin — no brackets/pools/scores/multi-day. Scope docblocks on `TournamentScheduleGeneratorService` + `TournamentScheduleBuilderForm`; info notice now renders atop the schedule builder; Tournament `format` field options labeled "(not implemented)" for bracket/pool_play (options kept so existing data stays valid).
- **Decision #2 (destructive regenerate):** by design for the pre-season board-meeting workflow (generate candidates → snapshot → restore winner → publish). By-design comments at `clearSchedule()` in both generators. **Hardened both `restoreSnapshot()`s:** restoring into an empty grid (after Clear All Games) now errors with "generate first, then restore" instead of a false success; partial restores WARN with the count of snapshot entries that had no matching slot. Operating rules recorded in the review doc: finalize teams/slots/fields/dates before generating candidates; no Clear All Games mid-meeting; never regenerate after week 1.
- **S3 (jersey colors) — original finding was WRONG-way-round; league is Red vs White, no home/away mentality.** Grid (red top row / white bottom), next-game banner, and iCal feed were all consistent: home_team slot = Red. Only the Game entity docblocks were backwards. Fixed the docs (home_team = the Red team, away_team = the White team, with a do-not-"fix" warning comment) and renamed the one player-facing home/away verbiage — `exportPdf` column headers → "Red Team"/"White Team". Admin field labels left as Home/Away Team (renaming ripples through admin forms; descriptions explain the mapping).
- **S7 (red/white coin flip per game):** as designed — flip only decides jersey color for the night; no balance tracking wanted. Comment at the flip in `ScheduleGeneratorService`.

### Fixed
- **T6:** unguarded `new \DateTime($dob)` in `TournamentRosterBuilderForm` — one malformed DOB (D7-migration risk) 500'd the whole roster builder. Now try/catch → default age 30 + watchdog warning naming the player. Sweep confirmed every other DOB parse in the module was already guarded; this was the one drifted site.
- **R1:** admin Teams list showed "0 players" on every season team (counted `Team.players`, which the season roster builder never writes — season truth is `Registration.team`). Season mode now counts registrations (`status IN paid/active` — cancelled players drop out automatically); tournament mode keeps `Team.players`. Deliberately no dual-write (that pattern caused the tournament ghost-member desyncs).

### On hold — Andrew deciding, NO changes made
- **S4/TS3 (drag-swap validation):** blocking is NOT wanted — mid-rearrangement legitimately passes through broken states, and the workbench was built as the staging area to avoid the hard block direct moves have. Proposed design recorded in the review doc (`findScheduleConflicts()` + non-blocking warning banner + single confirm at publish); awaiting Andrew's decision. Note current inconsistency: season moves hard-block, swaps check nothing, tournament checks nothing.

### Files touched July 18 (all in `web/modules/custom/ccsoccer/`)
`src/Service/ScheduleGeneratorService.php`, `src/Service/TournamentScheduleGeneratorService.php`, `src/Form/TournamentScheduleBuilderForm.php`, `src/Form/TournamentRosterBuilderForm.php`, `src/Entity/Tournament.php`, `src/Entity/Game.php`, `src/Controller/ContentController.php`, `src/TeamListBuilder.php`, plus `ADMIN_TOOLING_REVIEW.md`.

### Still open from the small-fixes list
G4/G5 (cancelled games render as normal in public per-season iCal + printable PDF — per-user feed is correct), G3 (3 pm cancellation-reminder cron only fires if a tick lands in 15:00–15:04, plus unlocked read-modify-write dedupe race).

### LOCAL test checklist for July 17+18 changes (before PROD)
1. Refund path: cancel a test season registration with Commerce refund on TEST gateway → succeeds, payment flips to Refunded. **(Also: Andrew to manually refund Avi's $133 — order 87 Payments tab → Refund — and email him.)**
2. Email-only invite → Mailpit email w/ working link (board address); non-allowlisted address → warning message, Send Reminder available.
3. Tournament reg with pending team invite via "no team yet"/pool → invitation stays pending.
4. Snapshot: save → Clear All Games → restore = instructive error; regenerate → restore = schedule back.
5. Roster builder: bad DOB row renders (age 30 + watchdog warning); `/admin/ccsoccer/teams` season mode shows real counts.
6. Schedule builder shows the single-day scope notice.

---

## Session Work — July 17, 2026 — Audits (Claude) + 3 fixes, UNCOMMITTED on main working tree

### Four review documents produced (repo root) — reference these when reviewing PRs

1. **`INVITATION_FLOW_ANALYSIS.md`** — all season/tournament invitation+acceptance paths documented. Confirms Caleb's hypothesis on the Brent/Myk data issues, with one refinement: the early-accept happens in `GroupPane::submitPaneForm` (checkout step 2), not the token link itself. 4 bugs + 7 edge cases + **Section 5 = review checklist for Caleb's upcoming invitation PR** (incl. required data repair for orphaned `accepted` invitations). Tournament pane already does it right (defers accept to order completion) — the fix pattern is in our own codebase.
2. **`ORDER_FLOW_EDGE_CASES.md`** — order pipeline silent failures. Top items: transactional emails (receipts, invitations) suppressed by notification preference `none`/`text`-without-phone; flagged "paid but broken" orders still have no admin surface (checklist item 18 — now live-critical); waitlist offers never expire (entry stuck `offered`, next-in-line never offered, reserved spot held forever); DEBUG noise in `send()` burying real errors in dblog.
3. **`SEASON_TOURNAMENT_DRIFT_AUDIT.md`** — systematic twin-pair comparison (season↔tournament). 20 divergences, 3 critical: D1 refund 100x (fixed today, see below), D2 admin-accept of tournament invitation crashes on a nonexistent `tournament` field query (`GroupInvitationsForm.php:1279`), D3 tournament order completion never re-checks active/visible (season's "Finding F" guard was never ported). D4: co-captains see Remove buttons but every click fails.
4. **`ADMIN_TOOLING_REVIEW.md`** — roster builders + schedule generators + game-day ops (~45 findings) ahead of first board use this fall. **Three board decisions needed:** (a) tournament schedule builder has NO pool/bracket support, no score fields, single-day only — decide format plan for fall tournaments NOW; (b) regenerate on both schedule builders hard-deletes all game history — operating rule until fixed: snapshot before regenerate, never regenerate after week 1; (c) cancellation notices don't reach preference-`none` players and don't work at all for tournament dates. Also: admin Teams list shows "0 players" for season teams (reads wrong field), jersey colors inverted in player schedule (verified), one bad DOB 500s the tournament roster builder.

### Production incident — order 87 (Avi, $133, cancelled Jul 8) — VERIFIED refund never happened

Season cancel form's "Refund via Commerce" requested a 100x amount ($13,300); Commerce rejected it; registration cancelled anyway; **no money moved, no email sent to player**. Payments tab shows "Refunded: $0.00"; credits $0.
- **TODO Andrew:** refund $133 via order 87 → Payments tab → Refund button (Commerce core form, safe) → verify payment state flips to Refunded → email Avi (he was never notified).

### Three code fixes made today — UNCOMMITTED, all independent, none touch Caleb's season/GroupPane territory

1. **D1 — refund 100x** (`src/Form/CancelRegistrationForm.php`): dropped the `* 100` (Price takes dollars — tournament forms were already correct); reject $0 refunds; failure message now says explicitly that no money moved + manual remediation steps. **Deploy first, own commit — live money bug.**
2. **BUG 4 — email-only invitees never got the invite email** (`src/Service/NotificationService.php` + `src/Controller/GroupController.php` + `src/Drush/Commands/CcsoccerCommands.php` + `templates/ccsoccer-group-manage.html.twig`): new `sendInvitationEmail()` (raw address, reply-to inviter, board allowlist still applies); wired into `invite()`/`nudge()`; `notified` now stamped only after successful send (failure shows warning, invite stays and Send Reminder works immediately); template guarded against empty `notified` (was fataling — found in Andrew's LOCAL test). **After deploy run `drush ccsoccer:send-pending-email-invites --dry-run` on PROD, then live** — repairs existing pending email-only invites (Matt Willis et al.).
3. **S2 — tournament registration silently declined ALL pending team invitations** (`src/EventSubscriber/OrderCompleteSubscriber.php`): decline now gated on `if ($team)` — pool/"no team yet" registrants keep invitations pending (mirrors season's `invitation_status === 'accepted'` guard); also un-breaks the capacity-exceeded fallback's leave-pending promise.

Deploy for all three: code only, no cim/updb, `drush cr` in each environment. LOCAL test notes are in each doc next to the ✅ IMPLEMENTED markers. Syntax-validated (PHP 8 parser); not yet run through a full LOCAL regression — test before PROD.

### Coordination with Caleb
- His invitation-timing PR: evaluate against `INVITATION_FLOW_ANALYSIS.md` Section 5 (esp.: accept must move to order completion, pane must rebuild from order-data selections not pending status, `removeMember` invitation cleanup, and the data-repair migration — code fix alone leaves prod data inconsistent).
- Today's fixes touch `GroupController::invite()/nudge()`, `OrderCompleteSubscriber::createTournamentRegistration()`, `NotificationService` — flag to him to rebase/merge before his PR if he's in those files.

---

## Session Work — July 8, 2026 (later session) — branch `feature/update_registration_page`

### 1. Registration page shows already-registered items (commit 3330566)

Problem: `/register` was empty for fully-registered players (bad UX — they had
to know to go to `/my-registrations`). Root cause: `RegistrationController::available()`
already split visible seasons/tournaments into registered vs available arrays,
but the registered arrays were **built and never rendered**.

- Registered cards now render in the same Leagues/Tournaments sections, after
  available ones: muted card (`season-card--registered`), green "✓ You Are
  Registered" box, **outline** Manage Group / Manage Team button (My
  Registrations style). Solid red buttons now mean only "Register".
- Tournament registered button renamed "Review Team" → "Manage Team" to match
  My Registrations.
- **Filter pills now honored by registered cards too** (second iteration —
  first pass showed registered items on every filter). Filter check moved
  ahead of the registered/available split in both loops. Coed shows only coed
  (available + registered), Men's only mens, Tournaments only tournaments,
  All shows everything. Side benefit: filtered-out items skip per-user
  registration queries.
- Added `url.query_args:filter` cache context (page previously varied only by
  user — cross-filter cache bleed risk under dynamic page cache).
- Empty-state check simplified — all four arrays honor the filter now.
- Home page buttons: dropped "Open" → "See Coed Registrations", "See Men's
  Registrations", "See Tournament Registrations" (`ContentController.php`).
  Rationale: page now also shows registered items, and players registered for
  the current season (summer) must still find the next season (fall) there.
  `CONTENT_PAGES_CSS_CHECKLIST.md:85` updated to match.
- Files: `RegistrationController.php`, `registration.css`,
  `ContentController.php`, `CONTENT_PAGES_CSS_CHECKLIST.md`.

### 2. Tournament teams: captain badged inline (commit 44d337b)

Captain appeared twice on `/tournament-teams` cards ("Captain:" line + roster).
Now: no "Captain:" line; captain sorts first with red CAPTAIN pill, co-captain
second with gray CO-CAPTAIN pill, rest alphabetical. Current-user highlight
unchanged. Fallback: "Captain:"/"Co-Captain:" line still renders **only** when
that person is not in the players list (admin-created teams, unregistered
captains). Files: `ContentController::tournamentTeamsPage()`,
`content-pages.css` (pill styles matching My Registrations badge).

### Deploy — code only, no cim/updb; run in EACH environment
```bash
drush cr
```
Verify after deploy: `/register` as a fully-registered player (cards, not
empty), filter pills show only their category, `/home` button labels,
`/tournament-teams` captain pills + fallback line on captain-only teams.

---

## Previous Session Work — July 8, 2026 (earlier) — ✅ MERGED via PR #115

### Season + tournament dates on player-facing pages

Players could not see season/tournament dates anywhere on `/register` or
`/my-registrations` — dates were admin-only. Root cause on the tournament card:
`buildTournamentCard()` checked a **nonexistent `tournament_date` field** (dead
code — entity uses `start_date`/`end_date`), which is why the SLO Friendly card
showed no date. Season cards never read dates at all.

#### What changed (6 files)
- **`src/DateRangeFormatTrait.php`** (new) — shared formatter. Single day →
  "Saturday, September 5, 2026"; same-year range → "Aug 20 – Oct 15, 2026";
  cross-year range → years on both ends. Falls back when only one date set;
  returns '' when neither. (`formatDateRange()` remains duplicated in
  SeasonListBuilder/TournamentListBuilder — refactor to the trait is optional
  cleanup later.)
- **`src/Controller/RegistrationController.php`** — season cards add
  "Season Dates: …" under the League line; tournament cards use
  start_date/end_date ("Date: Saturday, September 5, 2026"); pending
  invitations banner shows dates.
- **`src/Controller/GroupController.php`** — `myRegistrations()` adds a
  preformatted `dates` string to season, tournament, and pending-invitation
  rows (entities already loaded, no extra queries).
- **`templates/ccsoccer-my-registrations.html.twig`** — muted date line under
  the title on season cards, tournament cards, and invitation cards.
- **`css/registration.css`** — `.registration-card__dates`,
  `.invitation-card__dates`, `.invitation-item__dates` (small, muted).

#### Test on LOCAL (drush cr first — new class + template + CSS cache-bust)
1. `/register` logged out and logged in — dates on both league cards and the
   tournament card.
2. `/my-registrations` — dates under Mens/Coed/SLO Friendly cards.
3. Pending invitation cards on both pages show dates.

#### Deploy — run in EACH environment (LOCAL → TEST → PROD)
Code only; no `cim`, no `updb`.
```bash
drush cr
```

#### Unrelated note
`web/sites/default/default.settings.php` scaffold drift (from Caleb's core
bump — see June 26 archive note) is still uncommitted. Keep it OUT of the
date-display commit; commit separately with the core upgrade.

---

## Anonymous checkout lockdown — step 2 DONE on LOCAL; steps 1, 3, 4 still open

Full write-up: `ANONYMOUS_CHECKOUT_LOCKDOWN.md`. Reviewed against Caleb's
email July 5; the doc's 4-step plan stands. His route-subscriber default-deny +
anonymous route sweep is good follow-on hardening (slot next to checklist item
17/CSP), not a launch blocker.

### Background (Wayne / order 61)
Wayne (Bakersfield captain) bought "Team Fee — SLO Friendly 2026" while NOT
logged in — order 61, no user account tied to it. Root cause in `config/sync`:
the checkout flow's `login` pane is `_disabled`, and the anonymous role had
`access checkout` + `view commerce_product`. Season/tournament/jersey are
protected only per-product, not by a global login gate — so any product
exposing a standard add-to-cart form was anonymously buyable (Team Fee, any
`default`-type product, leftover `security_metrics_test_product_ty`). Risk:
orphaned orders and **card testing against the live Authorize.net gateway**.
Invite flow NOT affected (invitees authenticate before checkout). Caleb
manually reconciled Wayne's team + disabled the product; next year's team-fee
flow should require login (checklist item 34).

### Plan status
1. **OPEN** — Re-enable `login` checkout pane, require login. NOT config-only:
   `CCSoccerCheckoutFlow::getSteps()` defines no `login` step, so flipping
   `step: _disabled` alone points the pane at a nonexistent step. Proper fix:
   add a `login` step first in `getSteps()` + pane config `step: login`,
   `allow_guest_checkout: false`. Decision open: `allow_registration`
   true/false (false probably fine). Test LOCAL → TEST before PROD.
2. **✅ DONE on LOCAL** — removed `access checkout` from anonymous
   (`config/sync/user.role.anonymous.yml`, `commerce_checkout` dependency
   removed). Validated: logged-out `/checkout` is 403; logged-in checkout and
   invite flow unaffected. This alone closes the carding hole.
   Deploy to TEST then PROD: `drush cim -y` (no cr, no updb), then verify
   logged-out `/checkout` 403 + a logged-in checkout completes.
3. **OPEN** — Remove `security_metrics_test_product_ty` test product type/product.
4. **OPEN** — Keep Team Fee unpublished; next year's flow login-required (item 34).

### Team pages PII check (Caleb's question) — ✅ CLEAR
No member phone/email/names reach anonymous visitors on any team surface;
iCal feed token-validated; anonymous lacks `access user profiles`. Minor nit
(no action): `/teams` gate lives in the controller, not the route.

### Discovered: media display config drift loop (pre-existing)
`core.entity_view_display.media.image.default`, `…media.image.media_library`,
`…media.document.media_library` show `Different` immediately after `cim` —
media_library re-tweaks its own displays after save. Likely fix: `cex` just
those three files (remember beta_tester gotcha). Flag to Caleb.

---

## Open security follow-ups (from July 3 review)

Details in `CC_Soccer_Security_Review_2026_07_03.md` + archive.

- **E (MEDIUM)** — `JerseySelectionPane::submitPaneForm` trusts submitted
  `variation_id`; verify it is a jersey (SKU `JERSEY-%`) before adding to cart.
- **I (LOW)** — Scrub + rotate Authorize.net **test** creds and reCAPTCHA
  **secret** from git.
- **K (LOW)** — Generic exception messages (stop returning `$e->getMessage()`),
  move inline `onchange` in `ReportController.php:393`, escape admin notes on
  any future display.
- **G residual** — gate `invitee_email` fee waiver on the `invitee` reference
  before team fees scale next year; docblock cleanup
  (`GroupInvitationsForm.php:24`, `Team.php:655-657`).
- **Flagged-orders admin report** — surface `ccsoccer_credit_shortfall`,
  `ccsoccer_season_registration_failed`, `ccsoccer_team_name_collision`,
  `ccsoccer_tournament_full`, `ccsoccer_team_capacity_exceeded` (checklist
  item 18; design in `FLAGGED_ORDERS_REPORT_PROPOSAL.md`). Interim: filter
  `/admin/reports/dblog` by `ccsoccer` channel.
- **#3 (watch)** — `filter_htmlcorrector` removed from full_html; unclosed tags
  in pasted rules HTML could break page DOM. Fix when rules re-entered natively
  next year.

---

## Current State

### 🎉 SITE IS LIVE at ccsoccer.com
Soft-launch mode. Board members + beta testers testing. Tournament registration open for SLO Friendly 2026. Team-paid feature live and dark (no-op until a team is flagged).

### Code — main has PR #115 (date display) merged; `feature/update_registration_page` (2 commits) awaiting PR. TEST/PROD deploy status of both: verify.

---

## Combined Pre-Launch Checklist

### 🚨 Immediate

1. ~~**Caleb** — Audit what `drush cim` overwrites on PROD.~~ ✅ DONE

2. **Caleb** — Team names refactor (Phase 1 complete)
   - 2a. ✅ DONE
   - 2b. `/admin/ccsoccer/team/add` pre-filter by series — still open
   - 2c. ✅ DONE
   - 2d. Phase 3: Roster builder verification
   - 2e. Add league/series columns and filter to team names taxonomy overview
   - 2f. ✅ DONE
   - 2g. Admin "team-name collision review" view — still open

---

### Before Opening Whitelist to Public

3. **Andrew** — Vanishing CAPTCHA: confirm stays visible after correct CAPTCHA + wrong password.

4. ✅ DONE **Andrew** — Create real SLO Friendly 2026 season + tournament.

5. **Caleb** — Commerce checkout end-to-end with real card on PROD (season registration + jersey-only paths).

6. **Geo-blocking** — Site is now public and receiving Russian spam bot traffic via the contact form.
   - 6a. **Replicate D7 modules (preferred first step)** — Smart IP + Country Block. Smart IP needs IP2Location LITE BIN file (free, monthly update). Try before Cloudflare.
   - 6b. **Cloudflare geo-blocking (fallback)** — requires DNS rerouting through Cloudflare proxy.

7. **Caleb** — Remove IP whitelist from `web/.htaccess` on go-live day (both servers, skip-worktree protected).

8. **Andrew** — Done - Delete `ccsoccer-d11-migrated-200users.sql` from repo root if present.

9. ✅ DONE — Pre-launch security checklist.

10. ✅ DONE — Security Metrics test product.

11. ✅ DONE — PROD cron running every 15 min.

---

### Mid-term

12. ~~`test@ccsoccer.com` mailbox / from address.~~ ✅ DONE — June 20, 2026.

13. Andrew's local environment DB update.

14. `slofriendly` config role reference cleanup (Andrew).

15. **Caleb** — WebAuthn passkey 2.0.0-rc7 → 2.1.0-beta1. Composer advisory now resolved — do next session with full auth flow test.

16. Backup strategy verification.

17. CSP headers in report-only mode.

18. Admin view/report to surface orders flagged `ccsoccer_team_name_collision`, `ccsoccer_team_capacity_exceeded`, `ccsoccer_tournament_full`.

19. Confirm InMotion server's transactional email passes SPF/DKIM under DMARC `p=quarantine`.

20. Module/core updates — ready to run next weekend once team-paid has baked in PROD.

---

### Post-Launch / First 72 Hours

21. ✅ DONE — Assign `permanent_override` role.

22. ✅ DONE — Verify credit balances against D7.

23. ✅ DONE — Check credits/registrations after April 16 dump date.

24. Remove `beta_tester` role from all users.

25. `slofriendlysoccer.com` URL forward.

26. Archive/delete D7 waivers; eventually delete `ccsoccer_site_d7_archive/`.

27. Monitor login rate, password resets, unhandled exceptions for first 72 hours.

28. Add Devel back to TEST.

---

### Development (Deferred)

29. Three-tier button methodology pass.

30. CSS consolidation — design tokens across ~33 CSS files.

31. Team handling refactor.

32. Profile picture migration — board decision pending.

33. Fix remaining `CC Soccer` → `CCSoccer` in tournament deposit + jersey notification subjects/SMS bodies.

34. `#slofriendly` `#tournament-nextyear` — Team Fee product can be purchased anonymously (not logged in). Order 61: Wayne (Bakersfield captain) bought "Team Fee — SLO Friendly 2026" as Anonymous (not verified), no user account tied to the order. Not fixed now — Caleb manually reconciling Wayne's team + disabling the product. Next year's productized team-fee flow should require login (or capture/match purchaser identity) before allowing purchase.

---

## DB Quick Reference

### Production DB (live)
- DB: `n6ac4b5_d11live`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### TEST DB
- DB: `n6ac4b5_d11test`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### D7 archive DB
- DB: `n6ac4b5_ccsoccer`
- User: `n6ac4b5_ccsoccer_user`
- Password: `vGL3KWO(K8C;`

### Local D11 DB
- Admin: `admin` / `TJ4XxyYGCd`

---

## Server Directory Structure
```
/home/n6ac4b5/public_html/
  ccsoccer_site/            ← PRODUCTION (ccsoccer.com)
  ccsoccer_site_d7_archive/ ← D7 archive (subdomain removed; files still on disk)
  test_ccsoccer_site/       ← TEST (test.ccsoccer.com)
  slofriendly_redirect/     ← slofriendlysoccer.com redirect
```

---

## Email Architecture

### Pipeline
```
NotificationService / OrderCompleteSubscriber
  → Symfony Mailer pipeline
    → Inline CSS + Theme wrapper (email.html.twig)
    → URL to absolute + Wrap and convert
  → Google Workspace SMTP (TEST + prod)
  → Mailpit (local DDEV only)
```

### Key Files
- `web/modules/custom/ccsoccer/src/Service/NotificationService.php`
- `web/modules/custom/ccsoccer/src/Plugin/QueueWorker/NotificationQueueWorker.php`
- `web/modules/custom/ccsoccer/src/Commands/NotificationCommands.php`
- `web/themes/custom/ccsoccer_theme/templates/email/email.html.twig`

### Notification gating
- `site_instance = 'local'` → board members only (Mailpit catches all)
- `site_instance = 'test'` → board members + beta_testers only (real emails)
- `site_instance = 'production'` → all users

### Local invite email blocking
Non-production environments block invite emails to non-allowlisted addresses by design
(watchdog: "Email blocked to @to (not in board allowlist)"). This is correct behavior —
emails are generated but not sent. To test invite email content on LOCAL, invite a
board-member email address; it will land in Mailpit.

### Queue Worker — SMS suppression
When `sms_body === ''` (explicitly empty), `processItem()` calls `sendEmail()` directly,
bypassing `send()` which would strip HTML for SMS.

### From address — per environment (via settings.local.php, NOT config_ignore)
- **LOCAL**: `local@ccsoccer.com` — caught by Mailpit, never delivered
- **TEST**: `test@ccsoccer.com` — Google Workspace alias, same inbox as prod
- **PROD**: `ccsoccer@ccsoccer.com` — canonical value in `system.site.yml`; no override

Override pattern: `$config['system.site']['mail'] = 'test@ccsoccer.com';` in `settings.local.php`.

### SMTP config (in settings.local.php, never in git)
- Host: `smtp.gmail.com`, Port: `465`, TLS: true
- User: `ccsoccer@ccsoccer.com`
- App password: `sqygkfykzwrziota`

### reCAPTCHA Keys
- Site key: `6LchguosAAAAAC5kLFmKj0xCGdEWXNntLacANpVN`
- Secret key: `6LchguosAAAAANBJ8ikcrZvYQMxs6-FM2YHbzPEl`
- Domain-locked to production. LOCAL intentionally disabled — protected via `config_ignore`.

### Authorize.net Live Credentials (PROD only — never commit)
- API Login ID: `9Fus5B2a`
- Transaction Key: `7MA8r27R9KQrs3Kd`
- Public Client Key: `5ep4C2xSvyY4jpra4694guKsJyV6XGq2CB39SHRtrs59wHH47avwTfKM7R7xJ7hF`
- Mode: Live / Plugin: Authorize.net Accept.js
- Configure at: `/admin/commerce/config/payment-gateways`

### Drupal Update Notification Settings (per-environment, protected via config_ignore)
- **TEST**: weekly, `noreply@example.invalid`, security updates only
- **PROD**: weekly, `ccsoccer@ccsoccer.com`, all newer versions

---

## Key Facts / Gotchas

### config_ignore — environment-specific config that must never sync
```yaml
ignored_config_entities:
  - 'commerce_payment.commerce_payment_gateway.*'
  - 'update.settings'
  - 'recaptcha.settings'
  - 'captcha.captcha_point.*'
```
**When adding entries:** hand-edit the file directly, `git add` by name only — never blanket `cex`.
**After changing:** run `drush cim` on LOCAL to import the updated ignore list into active config.

### config drift check
```bash
drush config:status
```
Run before any `cex`, after deploys, and before merging feature branches. Reports `Only in sync`
(not imported), `Only in active` (drift — not exported), and `Different` (mismatch). `Different`
is the dangerous one. Edit config YAML directly in `config/sync` rather than via UI to avoid drift.

### Role config drift on LOCAL — unresolved, watch for recurrence
June 16: `user.role.anonymous`, `user.role.board_member`, `user.role.tournament_director`
showed unexpected drift. Cause unknown. Reverted via `drush cim`.

### beta_tester role — do not delete from config/sync
Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`.

### Google SMTP App Password
`sqygkfykzwrziota` — in `settings.local.php` on TEST and PROD servers only.

### Symfony Mailer — User override
Do NOT enable the "User" override — replaces HTML with plain text.

### DB import fix for InMotion
```bash
gunzip -c dump.sql.gz | sed 's/DEFINER=[^*]*\*/\*/' | gzip > dump-clean.sql.gz
```

### Two-file CSS sync
- `web/modules/custom/ccsoccer/css/ccsoccer-base.css` (admin)
- `web/themes/custom/ccsoccer_theme/css/base.css` (public)

### Commerce cart view mode
The cart's "Item" column renders the variation in its `cart` view mode (not the order item title).
New product types need `core.entity_view_display.commerce_product_variation.{type}.cart.yml`
or the cart falls back to rendering fields (showing "Price" as the label).

### composer install on servers
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php /opt/cpanel/composer/bin/composer install --ignore-platform-req=ext-intl
```

### TournamentCancelRegistrationForm — hidden number field gotcha
Remove `#min`/`#max` from hidden number fields; validate server-side instead.

### ConfirmFormBase + entity-typed route parameters
Do NOT put entity-typed parameters in route paths for `ConfirmFormBase` forms — use raw integer only.

### DMARC aggregate reports
Daily from `noreply-dmarc-support@google.com` and `dmarcreport@microsoft.com`.
Gmail filter to skip inbox + label. Check after any outbound mail config change.

### team_paid flag
Set on Team entity edit form (admin/TD only). Not visible to captains. Dark deploy — no-op
until explicitly set. Next year: Team Fee product checkout sets it automatically.
TeamPaidOrderProcessor (priority 200) applies a LINE-ITEM adjustment (not order-level) so
getSubtotalPrice() reflects the zero — prevents double-discount with DiscountOrderProcessor (100).

---

## Server Quick Reference
```bash
# SSH in
ssh ccsoccer

# Full deploy with DB updates
ccsDeploy && ccsUpdb && ccsCim && ccsCr
ccsProdDeploy && ccsProdUpdb && ccsProdCim && ccsProdCr

# Drush full path (TEST/PROD)
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php vendor/drush/drush/drush.php -r web [command]
```

## .htaccess — both servers
Not in git — protected via `git update-index --skip-worktree web/.htaccess`

## Git Workflow
- Always `git pull` before `git push`
- `main` is the primary branch
- `settings.local.php` is NOT in git
- Always `git checkout config/sync/user.role.beta_tester.yml` after `drush cex`
- When editing `config_ignore.settings.yml`, commit that file by name only
