# CC Soccer D11 — Session Handoff

**Date:** October 11, 2026 · cleaned up today. Every session entry whose work is merged to `main`
was removed; the lasting gotchas from them were moved into *Key Facts* below. The Sep 20 version is
archived in full, unmodified, at `archive/SESSION_HANDOFF_2026_09_20.md` (and the one before it at
`archive/SESSION_HANDOFF_2026_08_28.md`). **Nothing was lost.**

> ### Deploy state — read this first
>
> **`main` is at `0cdecd1` (Sep 21)** and holds all the work this file used to describe: the Sep 7
> refund fix (`f90f72d`, update hook `9075`), the Sep 20 schedule generator fix (`3c7188a`,
> `added59`, `8fc2ad6`), the validate-move fix (`24578a2`) and Drupal core 11.4.7 (`ef3c587`).
> **Server deploy state was not re-checked in the Oct 11 session.** If in doubt, `drush
> updatedb:status` on each box confirms `9075` ran — it adds the `deposit_forfeit_amount` column,
> and the Captain Deposits page throws without it.
>
> **Unmerged:** `fix/photo_hover_resize` (`58dcecf`) — see the Oct 11 entry just below.
>
> Deploy state is still the thing this file is most likely to get wrong. Check git and the servers
> when a specific fix matters.

---

## Oct 20, 2026 — login accepts username OR email address

**On `fix/login_with_email`, uncommitted. Code-only: `ccsoccer.module` only. No update hook, no
config, no new module.** Addresses the live theory in OUTSTANDING_ISSUES §P11 (*"username vs email at
login"*): migrated D7 players type their email on `/user/login`, core accepts username only, so they
fall back to `/user/password` every time.

- **What changed** (`ccsoccer_form_user_login_form_alter()`): the field is labelled *Username or email
  address* (matching the reset page) and `#maxlength` is raised from 60 to 254. A new `#validate`
  step, `ccsoccer_login_resolve_email()`, runs **before** core's `::validateAuthentication`: it trims
  the input, tries it as a username first, and only if no account has that username and it contains
  an `@` does it load the account by `mail` and hand core the account name. It needs exactly one
  match, and the account must be **active**: core prints the account name in its blocked-account
  message, so resolving a blocked account would show a stranger the username behind an email.
  Blocked accounts therefore get the same generic error as before. Core's password
  check, flood control and blocked-user check run unchanged. `ccsoccer_login_error_message()` runs
  after `::validateFinal` and rewords only core's generic failure to *"Unrecognized username, email
  address or password"*; the blocked, flood and CAPTCHA messages are untouched. The passkey help text
  now says *username or email address*.
- **Edge cases — run both on PROD before deploying, expect zero rows:**
  - If player A's username equals player B's email, that string still logs in as A (today's
    behaviour); B must use their username.
    `SELECT a.uid, a.name, b.uid, b.mail FROM users_field_data a JOIN users_field_data b ON a.name = b.mail AND a.uid <> b.uid;`
  - Two accounts sharing an email (possible in migrated data): that email resolves to nobody and
    gets the generic error. `SELECT LOWER(mail), COUNT(*) FROM users_field_data GROUP BY LOWER(mail) HAVING COUNT(*) > 1;`
- **Known trade-off (accepted):** anyone who knows a player's email can now trip the per-account
  flood lockout, where before they needed the username. Same as core's behaviour for usernames;
  CAPTCHA and the IP limit still apply.
- **Checked:** `php -l`, and the three functions run against stubbed form state (username, email,
  mixed-case email with spaces, the A/B collision, blocked account, duplicate email, legacy email that
  fails today's validator, unknown email, plain text, whitespace only; error rewording with and
  without a CAPTCHA error, blocked message). **Independent review (Oct 20):** OK with minor changes,
  verified against core `UserLoginForm`, `FormValidator`, `FormState`, `wa` and `captcha`. Flood is
  keyed by uid, so alternating username/email gives no extra attempts. Fixed from it: the
  blocked-account username leak above, plus tidy-ups (`use` statements, `str_contains`, `@param`
  docs, `#maxlength` as `max()` like core). **Not yet run against a site.**
- **LOCAL test plan:** (1) log in with username; (2) log in with email; (3) email in different case;
  (4) wrong password with email → new message, CAPTCHA still rendered; (5) blocked user: by username →
  core's blocked message, by email → the generic *Unrecognized…* message (no username shown); (6) passkey autofill still offered and works; (7) 5 wrong passwords for one
  account via email → per-user flood lockout still triggers; (8) `/user/password` unchanged.
- **Deploy:** code pull + `cr` (new `#validate` callbacks are picked up on cache rebuild).

---

## Oct 12, 2026 — R6: group members saved on a different team from their group

**On `fix/R6_syncGropuToRoster`, uncommitted. Code-only plus `cr` (services.yml changed). No update
hook, no config.** Brief: `R6_GROUP_TEAM_SYNC_BRIEF.md` (untracked); §0 records the scope chosen.

- **Symptom (season 49):** the Roster Builder showed 8 per team and `/teams` showed 6 and 9. The
  builder's `consolidateGroups()` draws group members in their manager's box without saving it, and
  the player-facing join paths set `group_id` but never `team`. Andrew repaired season 49 by hand,
  dragging each group to the Workbench and back.
- **Fix:** `syncGroupToRoster()` now runs after every season join: `GroupController::acceptSeasonInvitation()`,
  `RegistrationController::acceptSeasonInvitationDirectly()`, and `OrderCompleteSubscriber::createSeasonRegistration()`
  (wrapped in try/catch so it can never fail a paid order). Shift+drag and Option+drag use the
  manager's team. `move()` expands a drag to the whole **saved** group, and the JS reloads when that
  included players the page was not showing.
- **Behaviour changes to know:** a placed player who joins a group after rosters are built moves to
  the manager's team at once. If the manager is still on the Workbench, the joiner goes there too.
- **Checked:** `php -l` on all five PHP files, YAML parse, `node --check`, and `syncGroupToRoster()`
  run against stubbed registrations (9 cases, including both Late Fall shapes). **Not yet run
  against a site:** LOCAL test plan is in the brief, §7, items 1–3 and 6.
- **R5, same branch:** removed the legacy roster `save` endpoint (the route, `RosterBuilderController::save()`,
  the hidden "Save Changes" button and `changes` field, and the JS that hid the button). Nothing called
  it any more, and it cleared every team in the season before rewriting from whatever was posted. The
  `move()` concurrency half of R5 was **not** done, by choice: worth it only if two people edit
  rosters at once.
- **Independent review (Oct 12):** no blockers. Fixed from it: `mergeToGroup()`'s sync is now wrapped
  (it could report a saved merge as failed), and `findGroupManager()` uses `empty(invited_by target_id)`,
  matching `consolidateGroups()` (the two disagreed on a stored `0`). **Not fixed, by choice:**
  `OrderCompleteSubscriber` now requires the balancer as a constructor argument. Until `cr` runs after
  the code pull, a completed checkout would throw `ArgumentCountError` after payment. **Deploy with
  `ccsProdDeploy && ccsProdCr` in one chain, or in maintenance mode.**
- **Still open:** the invite-link accept ignores `groups_locked` (brief §4.4, not built). The roster
  builder AJAX routes do not require `_csrf_request_header_token` even though the JS sends the
  header; this is mitigated by `SameSite=Lax`, but it is cheap defence in depth.

---

## Oct 11, 2026 — player photo hover popup stayed small in Safari

**One commit on `fix/photo_hover_resize` (`58dcecf`), branched from `main` at `0cdecd1`. Unmerged,
undeployed. Code-only — no update hook, no config.**

- **Hover popup** (`season-players.js`, shared by the season players, All Players, Tournament
  Players and roster pages). The Aug 7 popup (`5fb6baa`) was an unsized `<img>` capped with
  `max-width`/`max-height`. Chrome showed portrait photos at 450×600; **Safari left them at the 50px
  thumbnail width.** `sizePopup()` now sets width and height in px from the photo's shape — 600×600
  square, 450×600 portrait, 600×450 landscape, smaller on a small screen — and also enlarges photos
  smaller than 600px, which `max-*` never could.
- **Upload shrink** (`ccsoccer_user_presave()`). `scale(1024)` capped only the width, so portrait
  phone photos were stored at **1024×1365**, and because the height stayed over 1024, **every save of
  the account re-saved the JPEG at quality 75**. Now `scale(1024, 1024)`, and it saves only when the
  size changes. Existing 1024×1365 photos shrink to 768×1024 once, on the next save of that account.

**Verified in headless Chromium only**, against test images; `php -l` clean; the resize maths checked
in a script mirroring core's GD `Scale` operation. **Not yet seen in Safari, not run against a site.**

---

## What this file is

Current state, what to do next, and the environment knowledge that does not belong in a tracker.
**It does not list open issues** — that is `OUTSTANDING_ISSUES.md`, and it is the only file that does.

| Kind of thing | Home |
|---|---|
| What is deployed | **git** |
| What is open | **`OUTSTANDING_ISSUES.md`** |
| What happened, how to resume | **this file** |
| How a fix should be built | the brief named on the item |
| Why the code is like this | analysis docs, mostly in `archive/` |

**The rule:** analysis and implementation briefs describe how the code works and what is wrong with it.
**They never say whether it has been fixed.** Only the tracker says that. That is what lets a two-month-old
analysis doc stay trustworthy — reasoning about code does not expire the way status does.

**Sessions get a short entry below and are then archived.** Do not let narrative accumulate here.

---

## Where things stand — October 11, 2026

Everything through Sep 21 is merged to `main`. One branch is open: `fix/photo_hover_resize`.

## Next steps, in order

1. **Check the photo hover popup in Safari** on LOCAL or TEST — portrait photos are the ones that
   failed. Then merge `fix/photo_hover_resize` and deploy code-only (`ccsDeploy && ccsCr`, then
   `ccsProdDeploy && ccsProdCr`). The `cr` matters: it makes admin browsers pick up the new JS.


## Open questions for Andrew and Caleb

- **Decision 2 — allowlist vs exclude-list for "live registration."** Still open. Blocks CF3 and B-15,
  and shapes the `CapacityManagerService`. **Full analysis is now in `DECISION_2_ANALYSIS.md` (Aug 6)** —
  read that rather than the older framing. It supersedes the counts in
  `archive/SESSION_HANDOFF_2026-08-05.md`, which were wrong in four places: the module has **four**
  conventions, not three (allowlist **49**, `paid`-only **7**, exclude-list **2** + the trait at 25 sites,
  and a previously-uncounted partial exclude-list `<> 'cancelled'` at **6** sites — four of which gate a
  duplicate *purchase*). Three things worth knowing before the huddle: `waitlist` is defined as live by
  the trait and as not-registered by `AdminController:270`; the three season-capacity checkpoints disagree
  on `reserved_spots`, not just on status, and that one is **not** behaviour-neutral; and **this does not
  block the PROD deploy.**
- **Caleb has not reviewed the D-8 package.** The code was written without waiting, on the reasoning that
  option D is *held* rather than rejected — so a late objection would be about which option ships, not
  about the code being wrong. `archive/D8_OPTIONS_COMPARISON.md` §10 is the brief for that conversation,
  and its Appendix C is worth his time regardless: it documents where this part of the codebase is sharp.
- **Board:** D17 (tournament roster freeze) and G1 (should cancellation notices bypass notification
  preference).

---

## Key Facts / Gotchas

### Scheduled jobs — cPanel cron, not hook_cron
**cPanel has been running Drupal cron all along** — this was not discovered until Aug 22, and not
knowing it is why the 3pm reminder was originally built as an hour check inside `hook_cron()`.
cPanel → Advanced → Cron Jobs:

| Env | Schedule | Command |
|---|---|---|
| TEST | `*/15 * * * *` | `drush cron` |
| PROD | `*/5 * * * *` | `drush cron` |
| PROD | `5 15 * * *` | `drush ccsoccer:daily` |

**Aliases do not work in cron.** It runs a bare non-interactive shell that never sources
`~/.bashrc`, so `ccsProdCr` and friends are unavailable — the entries use full paths and
`/usr/local/bin/ea-php83`. Every entry ends `>/dev/null 2>&1`; without it the `*/5` job emails
`ccsoccer@ccsoccer.com` 288 times a day. Errors still reach dblog.

**`automated_cron` (3600) is a fallback only.** Any cron run refreshes `system.cron_last`, so while
the server job is healthy page-load cron never fires. If the cPanel job dies, it takes over within
the hour — which is the point of leaving it non-zero rather than disabling it.

**Anything needing a schedule gets its own drush command and its own cron entry.** Do not add a
clock check inside a hook, and do not bolt a second job onto `ccsoccer:daily` unless it genuinely
wants 15:05 daily.

**Reading the logs — use `watchdog:show`, not raw SQL.** `watchdog` stores the message template
and its arguments in separate columns and substitutes them at display time, so a `sql:query` shows
`ccsoccer:daily started at @time (@tz)@mode.` and looks like a logging bug. It is not. Use:
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php \
  vendor/drush/drush/drush.php -r web watchdog:show --type=ccsoccer --count=50
```
A healthy PROD heartbeat logs `ccsoccer:daily started` at about 15:05 each day.

### Deploys pull `main`
`ccsDeploy` / `ccsProdDeploy` pull **`main`** and already run `composer install`. A branch that was
pushed but not merged deploys nothing — the tell is a pull that moves only docs and composer saying
"Nothing to install". **Read what the pull actually moved before assuming a deploy landed.**

### Testing refunds
Commerce's Manual gateway has the same partial/full refund state logic as AcceptJs and makes no
network call, so most refund behaviour reproduces on LOCAL. The Authorize.net branch of
`TournamentDepositRefundForm` needs TEST, and the sandbox refunds only **settled** transactions —
budget a next-day retest. On money code, get an independent review before calling it done: the Sep 7
review found six defects in the fix itself.

### Player photos
One copy per player, in `public://player_pictures`. `ccsoccer_user_presave()` turns it upright and
shrinks it so the longest side is at most 1024px, **overwriting the upload**. The admin player lists
load that same file for the 50px thumbnail and the hover popup — no image style. The popup is sized
in JS by `sizePopup()`; **do not go back to `max-width`/`max-height` on an unsized `<img>`** — that is
what Safari got wrong.

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

Three `media_library` entries are known, expected drift. Anything else is real.

**On the servers it fails with a terminal colour error — on PROD as well as TEST.**
(`Invalid option specified: "bold"`. This file previously recorded it as TEST-only; corrected
Aug 23.) It is drush's table renderer, not a config problem. **Use `--format=json`:**
```bash
PATH=/opt/cpanel/ea-php83/root/usr/bin:$PATH /opt/cpanel/ea-php83/root/usr/bin/php \
  vendor/drush/drush/drush.php -r web config:status --format=json
```
`--no-ansi` also works. Both bypass the formatter and print the real answer.

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

### site_instance — check it before generating anything on TEST
`Settings::get('site_instance', 'production')` **defaults to production** if the line is missing from
`settings.local.php`. TEST carries real PROD SMTP credentials and a copy of real player data, so a TEST box
missing that line emails real players.
```bash
drush php:eval "var_dump(\Drupal\Core\Site\Settings::get('site_instance', 'production'));"
```

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

### commerce_order.order_number vs order_id
The number shown to customers and in the Commerce UI ("Order 334") is `order_number`, generated
only once an order is placed — **not** the internal `order_id`. They are frequently different
values. Query `order_number` when working from what a customer/admin reports seeing on screen;
`order_id` is what every other table (`ccsoccer_registration.commerce_order`, etc.) actually
foreign-keys against. Bit us on Aug 4 — worth remembering.

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

### Flood limits bite during manual testing
`invite()` is 10 per 5 minutes per user; `nudge()` is 5 per 10 minutes. Repeated testing as the same
manager will hit these. Use several accounts or wait it out.

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

### registration_invite_only — what the checkbox actually closes
Tournament edit form, under *Registration Closes*. Defaults FALSE; PR #136, on PROD since Aug 23.
When ticked, only a player holding a **pending team invitation** can register. Enforced on three
surfaces — the `/register` card, `addTournamentToCart()` (the real gate), and the checkout pane
(options removed **and** re-validated server-side). "Invited" has one definition, in
`ccsoccer.tournament_invites`; do not write a second one.

**Two things it does that the field description does not say:**

1. **No admin bypass.** Age and photo gates let admins through; this one does not, deliberately —
   the pane has no matching bypass, so an admin would clear the gate and dead-end one step later
   with a cart already made. **An unregistered board member cannot register while it is on.**
2. **It closes `create` too**, not just free agent and pool — so **a late captain cannot form a
   team while it is on**, including a pre-paid one. The flag cannot express "no new free agents but
   a new team is fine"; all three uninvited routes close together.

Workaround for both: untick for a minute. **Not** the tournament equivalent of
`Season.groups_locked` — that is D17 and does not exist; this closes the front door while leaving
captains' doors open.

⚠ **Do not add a `registration_close` check to `addTournamentToCart()`.** It is the obvious next line
and it breaks the feature: invited players reach checkout through that same method via their token,
so the close date is deliberately display-only on that path. Warning comment is in the code.

⚠ **Fails open** — `isInviteOnly()` is `hasField()`-guarded, so code deployed without `updb` reads
every tournament as open and the checkbox silently does nothing. Ship code and hook together.

### Archive before editing
Copy any file you are about to change substantially to `archive/<name>_<date>.<ext>` first. That
convention has already paid for itself several times.

### Server shell quirks — they broke a deploy on Aug 23
The cPanel shell is **`dash`, not bash**, and `rm` is aliased to `rm -i`.

- **No process substitution.** `diff <(git show origin/main:path) path` fails with
  `/dev/fd/63: No such file or directory`. Use a temp file instead.
- **`rm` prompts.** An unanswered prompt silently breaks an `&&` chain and the deploy never runs.
  **Use `rm -f`** in anything chained.

### Never write session docs directly on a server
On Aug 23 `ccsProdDeploy` aborted because `archive/SESSION_2026-08-22-b.md` existed **untracked** on
PROD and the same path arrived tracked. Git will not clobber an untracked file with a tracked one, so
the whole deploy stops. Docs belong in the repo and reach the servers through git like everything
else. If it happens again: `git status --short` first (git reports these blockers **one at a time**),
confirm the copies match, then `rm -f`.

### Reading an incoming change without pulling
To review a merged PR before touching the working tree — useful when a deploy carries an update hook:
```bash
mkdir -p .review-<n>
git show origin/main:path/to/File.php > .review-<n>/File.php
git diff main..origin/main -- path/to/big-file > .review-<n>/big-file.diff
```
Everything is reproducible from the commands, so `rm -rf .review-<n>` when done. Keeps the code pull
and the `updb` — the part with no easy undo — as separate decisions.

---

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

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

# Code-only deploy (no update hooks, no config — e.g. fix/photo_hover_resize)
ccsDeploy && ccsCr
ccsProdDeploy && 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
