# CC Soccer D11 - Session Handoff
**Date:** July 28, 2026
**Uncommitted (this session):** roster-builder changes on the working tree — 2 code files + docs, **not linted, not tested, not committed** (see the July 28 section below). Decide branch/commit with Andrew.
**Branch:** `fix/team_display` — 1 commit (`9dfe05f`), **not yet tested, not deployed** (see July 27 evening section below)
**Also open:** `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`); July 20 late-night through July 26 archived in `archive/SESSION_2026-07-27.md` before this update.

---

## Session Work — July 28, 2026 — Review-doc cleanup + roster-builder admin overrides (Andrew + Claude)

Andrew asked for a pass over the six standing review docs (`INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md`, `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md`, `ADMIN_TOOLING_REVIEW.md`, plus the main handoff) to separate what has shipped from what is still open, focused on the roster/schedule/tournament work coming over the next few weeks. Explicitly out of scope per Andrew: abandoned-cart/abandoned-order edge cases (S3, S4, E5, E7, M-series) — ~200 players/season, affected players can email. Two small roster-builder code changes came out of the discussion; everything is uncommitted on the working tree.

### New tracker + doc annotations
- **`OUTSTANDING_ISSUES.md`** (new, repo root) — single prioritized list of open items: in-flight branches to land, P1 roster building, P2 scheduling, P3 tournament flow, P4 game-day, plus parked (abandoned-order) items and a "completed since these docs were written" summary. This is the pruned source of truth; the six review docs stay as detailed analysis of record.
- Added a **STATUS as of July 28** banner to the top of all five named review docs pointing at the tracker and summarizing shipped vs open for each.

### Code change 1 — season roster builder, admin group-cap override (`RosterBuilderController::mergeToGroup()`)
Andrew wanted to Shift+drag a player into a friend-group past the player-facing cap (3 coed / 4 mens) while arranging rosters — e.g. dragging Tom Do into a group already at 3. The Shift+drag path hard-blocked at `max_group_size` (`RosterBuilderController.php:365`, the "Group is at maximum size (3)" toast).

Traced the three distinct "caps" so we weren't conflating them: the **group cap** (`max_group_size`, friend-group size, what was blocking), the **team roster cap** (`getMaxRosterSize` — NULL/no limit for season teams), and the balancer's **`target_size + 2`** (auto-Suggest only). Only the group cap was in play.

Verified the only real downstream interaction before removing the block: the completion-time capacity guard in `OrderCompleteSubscriber::createSeasonRegistration()` (E2/E6) counts confirmed members vs max when a **pending** invitee completes checkout, and drops them from the group + flags the order if over. Dragging already-registered players never trips it; only an oversized group that still has an outstanding pending invitation is at risk.

Change: removed the hard block on the Shift+drag path only (admin path, gated on `generate rosters`). Player-facing `GroupController::invite()` still enforces the cap. Added a non-blocking warning to the success message when the resulting group is at/over max **and** still has a pending invitation, naming the risk so the admin resolves the pending invite rather than being surprised at checkout. `createGroup()` (Alt+drag) left alone — it starts at two players and any growth beyond goes through `mergeToGroup()`. Confirmed no client-side gate in `js/roster-builder.js` (server was the sole block).

### Code change 2 — tournament roster builder, over-capacity warning (`TournamentRosterBuilderController::move()`)
T1 (multi-drag force-adds past the team roster cap via `addPlayerToTeam(..., TRUE)`) was reviewed and **accepted as designed**: admin-only (`manage tournaments`), single-day SLO Friendly with no scores/standings, board validates team sizes before publishing. Verified nothing downstream breaks on an over-cap team — schedule generation reads registration counts as display metadata only and schedules team-vs-team regardless of size (`TournamentScheduleGeneratorService.php:516-544`); the checkout path re-checks `isFull()`/`isFullIncludingPending()` and flags `ccsoccer_team_capacity_exceeded` rather than silently overflowing (`OrderCompleteSubscriber.php:765,813`); `Team.players` is unbounded.

Change: added a non-blocking over-capacity warning to `move()` so an accidental overflow is visible at drag time. Force-add behavior itself is unchanged. Flagged that the real risk to the "validate before publish" workflow is not this overflow but the ghost-member/cancelled-registration drift (T2/T3/T5, D6/D7) — those make the count being validated unreliable. Ghost reconciliation deliberately deferred to a future session.

### Decisions recorded (as-designed / severity), no code
- **T1** — accepted as designed (above). Marked in `ADMIN_TOOLING_REVIEW.md` T1 row + banner + tracker.
- **R2** ("Suggest Rosters" leaves unplaceable players in the workbench without the toast saying so) — **downgraded HIGH → LOW.** Andrew's operating model is the compensating control: the board runs Suggest repeatedly as the league fills, then manually reviews the whole board and drags the workbench onto teams before publishing, so unplaced players are always seen and placed. The 14 players in the season-48 workbench were late registrants who signed up after the last Suggest run — expected, not a fault. Two modes documented: Mode B (leftover individuals go to `unassigned` silently — the common case) and Mode A (an oversized group ~11+, not reachable by 3/4 player groups, errors and clears the board after `clearRosters()` — theoretical, loud, recoverable). Optional future nicety noted only: surface the unassigned count in the toast.

### Files touched (all uncommitted)
- `web/modules/custom/ccsoccer/src/Controller/RosterBuilderController.php` (mergeToGroup override + warning)
- `web/modules/custom/ccsoccer/src/Controller/TournamentRosterBuilderController.php` (move over-capacity warning)
- `OUTSTANDING_ISSUES.md` (new), `ADMIN_TOOLING_REVIEW.md`, `INVITATION_FLOW_ANALYSIS.md`, `ORDER_FLOW_EDGE_CASES.md`, `SEASON_TOURNAMENT_DRIFT_AUDIT.md`, `INVITATION_FIX_LOCAL_TEST_CHECKLIST.md` (banners / status)
- Pre-edit archives: `archive/RosterBuilderController_2026-07-28.php`, `archive/TournamentRosterBuilderController_2026-07-28.php`

### Next session
1. **Lint + LOCAL test the two code changes before commit** — no PHP in the authoring sandbox, so `php -l` was not run. Season: Shift+drag a 4th player into a coed group (should now succeed), confirm the pending-invite warning appears when applicable, confirm a normal player-facing invite still hits the cap. Tournament: multi-drag a group onto a near-full team, confirm the over-capacity warning appears and the players are added.
2. Decide branch/commit with Andrew (suggested split: two code files as one commit, the markdown docs as another). Deploy is code only, `drush cr`.
3. Still queued from prior sessions (unchanged): land `fix/team_display` (untested), nudge fix to PROD, `feature/update_registration_page` PR, 11.4.4 soak → PROD, recursion-guard warning fix, verify Avi's refund (order 87). See `OUTSTANDING_ISSUES.md` for the full prioritized list.

---

## Session Work — July 27, 2026 (evening) — Tournament Manage Group display + removeMember, G3 reinvestigation (Andrew + Claude)

Branch `fix/team_display`, one commit `9dfe05f`. **Code is untested — no `php -l`, no LOCAL click-through, not on TEST.** That is the first thing to do next session.

### The report: the same team page showed different numbers to different people

Andrew, captain of Lunch Crew (SLO Friendly 2026), saw `Max 16 / Accepted 6 / Pending 9 / Spots left 1` at `/my-group/5093`. Yong Pong, on the same team, saw `Max 16 / Accepted 0 / Pending 9 / Spots left 7` at `/my-group/5096` — and no Team Roster table at all.

**There is only one view.** Both URLs hit `GroupController::manage()` and `ccsoccer-group-manage.html.twig`; nothing branches on captain vs player for the stats box. What it branched on was **the viewer's own registration row**: `$group_id = $registration->get('group_id')->value` at the top, then `if ($group_id)` wrapped the roster build. The tournament roster comes from `Team.players` and has no dependence on that field, so the gate was a leftover from the season path. Pending was already team-driven, which is exactly why the two numbers contradicted each other on screen — and the missing roster table was the confirming detail, since it meant `group_roster` was empty rather than miscounted.

Why Yong had no `group_id`: the Tournament Roster Builder **deliberately** leaves it NULL when an admin drags a free agent onto a captained team (`TournamentRosterBuilderController.php:160`, with a comment saying so). Andrew confirmed from the roster builder that Yong was dragged on by the tournament director, not invited by the captain. So this is a legitimate state and the display was wrong, not the data — no repair needed.

### What shipped in `9dfe05f`

**`manage()`** — roster and sent-invitations lookups now key on the Team entity for tournaments and on `group_id` for seasons only. Side effect: closes a latent fatal where a tournament registration with `group_id` set but no team fell through to the *season* branch and dereferenced `$season`, which is never assigned on the tournament path.

**`$is_season` added**, derived from `registration_type` rather than from `!$is_tournament`. Andrew pushed back on this as a significant change and the audit was worth it: `registration_type` is a required two-value enum, so the two are equivalent for every real row, and the behavior is byte-identical for anything typed `season` or `tournament`. All nine registration-creating `->create([…])` sites across the module pass the field explicitly. The only divergence is a row with an empty or unrecognized type, which previously took the season branch and now takes neither. `myRegistrations()` (line 121) and `removeMember()` already used strict positive matching, so this follows existing convention. **Not verified against PROD data** — one query would settle it:

```sql
SELECT id, player, registration_type, season, tournament, group_id, status
FROM ccsoccer_registration
WHERE registration_type NOT IN ('season','tournament') OR registration_type IS NULL;
```

Zero rows means the change is provably inert. Non-zero means those rows need fixing, not the guard weakening.

**`removeMember()` — D4 fixed**, per the drift audit's own prescription. Tournaments now authorize on `$team->isTeamLeader()`; seasons keep the `invited_by` rule. `$is_tournament`/`$team` had to move above the authorization check, which is why the diff is larger than the one-line fix D4 implies.

**Captain protection — required by the D4 fix, not defensive.** Until now no co-captain could pass the authorization check, so nothing needed to stop one from removing the *captain*. The template renders a Remove button on every row that is not your own, so granting co-captains access opened a one-click path to stripping the captain out of `Team.players` while `Team.captain` still pointed at them. `removeMember()` splices `Team.players` inline and never calls `TournamentTeamManager::removePlayerFromTeam()`, where the equivalent guard already lives (line 177). **Anyone reviewing D4 in isolation would have missed this.**

**T9 cleanup:** removing a co-captain now clears `Team.co_captain`, which otherwise kept pointing off-roster and left `isTeamLeader()` granting rights over a team that player had left.

**Membership check** is now `Registration.team` match **or** presence in `Team.players`, rather than `group_id` equality. Decision recorded because a strict check would also have fixed Yong's case: the on-screen roster renders from `Team.players`, and the two sources are known to drift (T2/T5/D7 — an admin-declined invitation clears `Registration.team` but leaves the player in `Team.players`), so a strict check would refuse to remove rows the page itself had just drawn. Neither arm can reach outside the team.

**Missing status filter — 6th confirmed instance.** `manage()`'s tournament roster loop loaded each player's registration by player + tournament with no status filter, and `reset()` takes the lowest id — so a player holding both a cancelled and a live registration had the **cancelled** row wired to the Remove button. The click would have appeared to do nothing. Extracted as `pickLiveRegistration()` (excludes cancelled, takes most recent). `nudge()`'s inline copy of the same pattern was deliberately left alone because it is mid-deploy to PROD.

**Docs:** D4 marked implemented in `SEASON_TOURNAMENT_DRIFT_AUDIT.md`. **D8 corrected** — it was listed open but shipped July 20 in `086d04d` and passed checklist section F on TEST; the doc was stale, not the code.

### G3 (3 pm cancellation reminder) — reinvestigated, **no code changed**, recommendation is deletion

Written up in full in `ADMIN_TOOLING_REVIEW.md` under a new "G3 — reinvestigated" section. Summary:

**What the cron actually does:** one thing — a second league-wide email + SMS to every `paid`/`active` player in the seasons with a cancelled game today. **It does not drive the status banner.** That was the assumption worth killing: `ccsoccer_page_top()` recomputes the banner on every page request from `date('G') >= 15`, so the old-site behavior of the header flipping at 3 pm is already reproduced with no cron involvement.

**Why it should go (Andrew's operational context):** the admin sends the cancellation with the rainout credits early in the day, and that is the only notification players get — it carries the reason and the credit action, which the reminder does not. Anyone checking later sees the banner and the schedule, which already renders rained-out games. The reminder re-notifies the same roster hours later with strictly less information, on an unmonitored mass-SMS path.

**The finding's second half is wrong.** Core's `Cron::run()` holds a global `cron` lock for 900 s before `invokeCronHandlers()`, so the "double-blast from concurrent cron" is not reachable and the recommended lock solves a non-problem. A different flaw was missed: send runs before the `reminder_sent` write. `sendBulk()` queues, so that window is milliseconds — but note the narrow 15:00–15:04 window is what currently contains it, so widening the window without fixing the ordering would have made things worse.

**A claim I made and retracted:** I reported the reminder had "never fired" based on 8 logged cancellation days with `reminder_sent` at 0. Andrew corrected me — that log is from the LOCAL dev site, where cron barely runs, so it proves nothing. The timing defect stands on its own reading of the code; the "never fired" evidence does not. **Check PROD before removing anything:** `drush config:get ccsoccer.game_status notification_log` and `crontab -l`.

**Season scoping is already correct** and needs no change: both notification paths collect season IDs from the games being cancelled, so a Tuesday rainout notifies Coed only and a Thursday one Mens 35+ only.

**Two existing findings this invalidates.** Rainouts are all-or-nothing — one park, multiple fields — so any concern about mixed-status dates is moot, including the banner's `$cancelled_count === $total_count` test. Same reason retires **G7's** "cancel hits all leagues sharing the date": only one league plays per night.

### Also noticed, not fixed

- **`notification_log` is runtime state stored in exported config.** `ccsoccer.game_status` is tracked in `config/sync`, so every notification writes active config and shows as drift in `drush config:status` — the same check the 11.4.4 deploy leaned on — and a `cim` rolls the live log back to the git snapshot. Cannot cause a double-send (wiping `initial_sent` only makes the guard stricter). Cure is `\Drupal::state()` plus an update hook. Same object as G7's unbounded-growth note.
- **`$is_manager` quirk in `manage()`:** a *tournament* registration with no team falls to the season `else` and gets `is_manager = empty(invited_by)` — TRUE for a pool player, who is then shown the "Invite a Player" form. `invite()` presumably rejects it, but the form should not render.
- **Four NULL-season deref sites in `manage()`** (461, 502, 554, and 635 in the render array). 635 fires unconditionally, so a season registration whose Season entity was deleted 500s the page regardless of the other guards. Low likelihood, pre-existing.
- **Banner caches 5 minutes**, so the 3 pm flip can lag to 15:05.

### Process note — my mistakes this session, recorded so they are not repeated

- I ran `git reset` to clear what I thought was stray staging. It was Andrew's, on a branch he had just created. The sandbox then could not delete git's lock files, leaving stale `HEAD.lock`, `ORIG_HEAD.lock`, `index.lock` and `refs/heads/fix/team_display.lock` that blocked his commit until he removed them by hand. **Do not run git write commands in this repo** — hand Andrew the command instead.
- I also overwrote `archive/GroupController_2026-07-27.php`, which already existed from the nudge fix, and had to restore it from HEAD; the new snapshot is `archive/GroupController_2026-07-27b.php`. Check for an existing archive filename before writing one.

### Next session

1. **Test `9dfe05f` on LOCAL** — `php -l`, then click through: captain removes a member, captain removes a co-captain, co-captain removes a member, co-captain attempts the captain (must block), a season group removal (must be unchanged). Then TEST, then PROD.
2. **Run the `registration_type` query above** to confirm `$is_season` is inert.
3. **Decide G3** — check PROD's `notification_log` first, then remove the reminder if it is as redundant as it looks.
4. Everything still queued from the earlier July 27 session below: **nudge fix to PROD**, 11.4.4 soak then PROD, recursion-guard warning fix, verify Avi's refund (order 87).
5. Ask Andrew about the unexplained 17-line deletion in `CcsoccerCommands.php` on `fix/tournament_nudge_500` — still unanswered.

---

## Session Work — July 27, 2026 — PROD verification, composer restore, tournament nudge fix, core 11.4.4 (Caleb + Claude)

### PROD confirmed for all three July 20 deploys
`086d04d` (invitation timing), `7e1dd4b` (jersey cleanup), `d466b97` (recursion fix) all on PROD — and confirmed *deployed*, not merely code-arrived. That distinction mattered: the broken composer binary sits first in `ccsProdDeploy`'s `&&` chain, so a failure there would silently skip `updb`/`cim` while leaving `git log` looking perfect. Verified via `updatedb:status` (clean) and, the real proof for `9070`, `config:get` on all three zombie fields returning "does not exist". Only drift is the three known media_library display entries — now confirmed present on PROD too, so it's a universal artifact, not a LOCAL quirk.

### `field_notification_preference` vs `field_notification_prefs` — right one deleted, confirmed
Two separate fields with near-identical names. **`field_notification_preference`** (singular) is alive and driving notifications — order 387 read it at runtime, got `text`, and routed to SMS accordingly, *after* 9070 ran on PROD. **`field_notification_prefs`** (plural) is the dead one 9070 removed. Allowed values on the live field: `email` / `text` / `both` / `none`. Note `none` is user-selectable and suppresses transactional mail too — relevant to any "reminders aren't arriving" report.

### Order 387 — recursion guard fired on PROD, but not for the reason the message says — **PARKED, needs a follow-up fix**
One warning at 04:27 ("nested place transition detected"). Investigated as a possible flagged-order incident; it isn't one. Order 387 is a clean registration (Emily Greene, uid 94626, $97, season 48 + jersey, registration 5361 created, SMS delivered) and its `data` column carries **no flag at all**.

Watchdog ordering shows the re-entry came *after* `Created registration 5361` — i.e. from the **save at the end of `onOrderPlace()`**, not a mid-method flagging save. That save always re-dispatched harmlessly (it sets `ccsoccer_completion_processed` before saving, so re-entry hits the persistent idempotency guard). The new static guard now sits in front of that one and logs on the way out. Two consequences:
1. **The warning fires on every completed order, forever**, and its own text ("expected when a flagging branch saves the order mid-completion") is wrong in the common case.
2. **It retroactively weakens the Kelly/E verification below.** "Exactly one warning (wid 189209)" was treated as proof the guard caught the capacity branch — but a normal order produces exactly one warning too. The fix is still almost certainly correct on its substance (registration created, `group_id` NULL, invitation pending, order flagged); that log line just wasn't the confirming evidence it was read as.

**Proposed fix (not built):** inside the static-guard branch, check `ccsoccer_completion_processed` on the in-memory order first. Set → known-benign end-of-method save → return silently. Not set → the mid-method case that used to blow the stack → warn. Restores the warning as a real signal.

### Composer restored on the server — root cause found
`/opt/cpanel/composer/bin/` exists but is **empty**, and both it and its parent are dated **Jul 20 21:10** — the same evening the deploy chain broke. A cPanel/EA update removed the package. Not local drift.

Fixed: installed composer **2.10.2** to `~/bin/composer` via getcomposer.org installer, then `sed`'d both `ccsDeploy` and `ccsProdDeploy` in `~/.bashrc` to `$HOME/bin/composer` (single-quoted so `$HOME` expands at run time, same as `$PATH` already did). Verified working through a real `ccsDeploy`. Backup at `~/.bashrc.bak-2026-07-27` — note it was taken *after* the first sed, so it holds the corrected version.

**Do not** wire the chain to `/opt/cpanel/ea-wappspector/composer.phar` — root-owned, inside another app's tree, equally liable to vanish on the next cPanel update.

### Tournament nudge 500 (D14) — fixed, `fa76cfd`
**Andrew's `fix/tournament_nudge_500` contains no code.** Three files: his handoff edits, the new `TOURNAMENT_NUDGE_500_FIX.md`, and an unexplained 17-line mostly-deletion in `CcsoccerCommands.php`. `GroupController.php` is not in the diff — his doc header says so ("NOT YET APPLIED"), held pending coordination with the invitation PR, a reason now stale.

His diagnosis was correct and verified against the real code: `nudge()`'s redirect block passed the invitation's `season` target_id to `loadByProperties()` unconditionally; team invitations carry no season, so NULL went in as an entity-query condition, which throws in D10/11. The block sits *outside* the if/else, so the 48h throttle path crashed too. His claim that `$is_tournament`/`$team` are scoped inside the >48h branch also checks out — fresh locals are genuinely required.

**Applied his patch plus one addition.** The redirect lookup had no status filter, so it returned every registration the user held for that season/tournament and `reset()` took the lowest id. Confirmed live on LOCAL: a cancelled tournament registration (4025) was picked over the live one (5083), producing "Reminder sent." followed by "This registration has been cancelled" on redirect. Now skips cancelled and takes the most recent. **5th confirmed instance of this missing-status-filter shape** (after `userSearch()`, `isFirstTimeRegistration()`, `invite()`'s eligibility check, `available()`'s already-registered lookup).

Excluding `cancelled` was chosen over allowlisting live statuses — the full status set isn't confidently known, and an allowlist that misses one silently breaks the redirect.

**Tested:** LOCAL — tournament nudge sends and lands on the right Manage Team page; 48h throttle path returns cleanly (both previously fatal). TEST — season nudge regression check passes, redirects to Manage Group as before. **Deployed to TEST. NOT YET ON PROD.**

**Important for anyone re-sending:** the crash happened *after* the email was sent and `notified` was stamped. Reminders that showed the error page **did deliver** — do not manually re-send. Andrew's framing ("captains are nudging and it isn't working") is half right: the mechanism worked, the feedback lied. Only the throttle path sent nothing.

### Core 11.4.4 + security updates — deployed to TEST, soaking
`composer audit` surfaced **14 advisories across 4 packages**; `composer outdated --direct` structurally could not show most of them, since `dompdf`, `guzzle`, and `webauthn-lib` are transitive. Worth remembering: **audit, don't just check outdated.**

Also learned: **11.3.14 would not have been enough.** `core-recommended` 11.3.13 pins `guzzlehttp/guzzle ~7.12.1`, making 7.15.x unreachable on 11.3.x — the minor bump is the delivery mechanism for the guzzle fixes.

Shipped in one commit (`d75ce2e`): core 11.3.13 → 11.4.4 (SA-CORE-2026-010/011/012), guzzle 7.12.3 → 7.15.2 (4), dompdf 3.1.4 → 3.1.6 (6), commerce 3.3.6 → 3.3.8, better_exposed_filters 7.1.2 → 7.1.3. **13 of 14 advisories closed.**

Also in the commit: the `default.settings.php` scaffold drift outstanding since the June core bump; a new `web/.gitignore` from 11.4 scaffold (ignores `/autoload_runtime.php`); and `allow-plugins: symfony/runtime` in `composer.json` — **required**, or `composer install` prompts and fails non-interactively on the servers.

**Config export was safe here specifically because `config:status` was clean immediately before the update** — every difference was traceable to a named update hook, none was ambient drift. 11.4 splits search: `search_update_11400` installs Search Node, `11401` installs Help Search (hence `core.extension`); `field_post_update_clear_purge_batch_size` removes `field.settings`.

**Gotcha, will recur on PROD:** TEST carried its own uncommitted copy of the June `default.settings.php` drift, which blocked `git pull`. Diff was pure scaffold churn (a commented-out oEmbed doc block), discarded with `git checkout`. That file is a template Drupal never reads at runtime — real config lives in `settings.php`/`settings.local.php`, neither in git — so discarding is safe. **PROD took the same June bump and will hit the identical block.**

Before `cim`, compared enabled-module counts: TEST 85, LOCAL 87 — exactly the two new search modules, so `cim` could only add, never uninstall. Worth repeating before any `cim` that carries `core.extension`.

TEST deploy clean: all 14 update hooks ran in the same order as LOCAL, `cim` imported without complaint.

**Deliberately deferred:**
- `drupal/symfony_mailer` 1.6.2 → **2.0.2 (major)** — touches every transactional email, and the pipeline has real customization layered on it (inline-CSS + theme-wrapper policy chain, per-environment from-address overrides, the "never enable the User override" gotcha). Needs release notes + upgrade guide read first. **Own session.** Note: `symfony/mailer` v7.4.14 in this update is the Symfony *component* riding along with core — different thing.
- `web-auth/webauthn-lib` → 5.3.5 — the 14th advisory, severity **low**, and it concerns `SimpleFakeCredentialGenerator` producing predictable decoy credentials (username-enumeration hardening), not the live passkey auth path. Tied to checklist item 15, which needs a full auth flow test not currently runnable.

### New findings, logged not fixed
- **`CartEventSubscriber` debug logging is live on PROD** — `onCartEntityAdd fired`, `Product type / ID / Quantity`, `First add (quantity = 1), allowing`, three notice rows per add-to-cart, plus a severity-7 line logging each user's notification preference by username. Same class as the `NotificationService::send()` DEBUG noise removed July 18; that pass just didn't reach this file.
- **Andrew's `CcsoccerCommands.php` change** on `fix/tournament_nudge_500` — 17 lines, mostly deletions, unmentioned in his doc, which is otherwise explicit about what to keep out of the PR. Not merged (we applied the fix directly instead). **Ask him.** That file holds `send-pending-email-invites`, already run live on PROD.
- **Season confirmation SMS reads "CC Soccer"** — checklist item 33 scopes that string fix to tournament deposit + jersey only; it's in season confirmations too.
- **`drush config:status` misreports** — listed two `core.entity_view_mode.node.search_*` entries as "Only in sync dir" when they existed in both active config and `config/sync`. Second unreliability from this command (after the Symfony Console "bold" crash on TEST). Trust `git status` after `cex` over `config:status` before it.

### Next session
1. **Deploy the nudge fix to PROD** — `ccsProdDeploy && ccsProdCr`, code only, no `cim`/`updb`. Tested on both LOCAL and TEST; the only thing holding it is sequencing.
2. **Let 11.4.4 soak on TEST** with Andrew testing — at least a day. Focus areas matched to what moved: schedule PDF export (dompdf), checkout end-to-end (Commerce + guzzle, which Authorize.net's client uses), email **rendering** not just delivery (`sabberworm/php-css-parser` 9.2 → 9.4 inlines the CSS, twig 3.27 → 3.28), and views with exposed filters (BEF post-update added a param key). Plus the tournament nudge, new on TEST.
3. **PROD 11.4.4** after the soak — full chain, expect the `default.settings.php` block, verify Authorize.net credentials after `cim`.
4. **Recursion-guard warning fix** (parked above).

---

## 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 — **11.4.4 + security batch deployed to TEST July 27,
    soaking.** 13 of 14 advisories closed. Remaining: `symfony_mailer` 2.x
    (major, own session) and `webauthn-lib` (item 15). See the July 27 session
    notes above for the PROD deploy gotchas.

---

### 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
Composer lives at `~/bin/composer` (self-installed 2.10.2, July 27). The old
cPanel path `/opt/cpanel/composer/bin/composer` was **emptied by a cPanel/EA
update on Jul 20 21:10** — don't go back to it, and don't use
`/opt/cpanel/ea-wappspector/composer.phar` either.
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php $HOME/bin/composer install --ignore-platform-req=ext-intl
```
If it vanishes again, reinstall:
```bash
mkdir -p ~/bin && cd ~/bin
curl -sS https://getcomposer.org/installer -o composer-setup.php
/opt/cpanel/ea-php83/root/usr/bin/php composer-setup.php --install-dir=$HOME/bin --filename=composer
rm composer-setup.php
```

### Security updates — audit, don't just check outdated
`composer outdated --direct` only shows packages you declared. Transitive ones
(`dompdf` via entity_print, `guzzle` via core, `webauthn-lib` via drupal/wa)
never appear, and on July 27 that was 11 of 14 advisories. Always:
```bash
ddev composer audit
```

### 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
